From e088eed41a57b0cf91a11819d5d1202d1272fbb7 Mon Sep 17 00:00:00 2001 From: David Durieux Date: Sun, 14 Jun 2026 22:00:49 +0200 Subject: [PATCH] Add e2e tests --- .github/workflows/e2e-client.yaml | 117 ++ .github/workflows/e2e-mock.yaml | 76 + e2e/ci/plugins.toml | 57 + e2e/package-lock.json | 1559 +++++++++++++++++ e2e/package.json | 25 + .../horizon/startup-persistence/scenario.json | 31 + .../step-01-receive-handshake.json | 6 + .../step-02-receive-get-all-items.json | 6 + .../step-03-send-items-end.json | 6 + .../persistence/get-all-items/scenario.json | 19 + .../step-01-send-get-all-items.json | 6 + .../step-02-terminal-items-end.json | 6 + e2e/src/client.ts | 156 ++ e2e/src/mock-server.ts | 218 +++ e2e/src/runner.ts | 53 + e2e/src/types.ts | 60 + e2e/src/validator.ts | 52 + e2e/test/scenarios.test.ts | 44 + e2e/tsconfig.json | 11 + schemas/e2e-scenario.schema.json | 90 + validate.js | 71 +- 21 files changed, 2658 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/e2e-client.yaml create mode 100644 .github/workflows/e2e-mock.yaml create mode 100644 e2e/ci/plugins.toml create mode 100644 e2e/package-lock.json create mode 100644 e2e/package.json create mode 100644 e2e/scenarios/horizon/startup-persistence/scenario.json create mode 100644 e2e/scenarios/horizon/startup-persistence/step-01-receive-handshake.json create mode 100644 e2e/scenarios/horizon/startup-persistence/step-02-receive-get-all-items.json create mode 100644 e2e/scenarios/horizon/startup-persistence/step-03-send-items-end.json create mode 100644 e2e/scenarios/persistence/get-all-items/scenario.json create mode 100644 e2e/scenarios/persistence/get-all-items/step-01-send-get-all-items.json create mode 100644 e2e/scenarios/persistence/get-all-items/step-02-terminal-items-end.json create mode 100644 e2e/src/client.ts create mode 100644 e2e/src/mock-server.ts create mode 100644 e2e/src/runner.ts create mode 100644 e2e/src/types.ts create mode 100644 e2e/src/validator.ts create mode 100644 e2e/test/scenarios.test.ts create mode 100644 e2e/tsconfig.json create mode 100644 schemas/e2e-scenario.schema.json diff --git a/.github/workflows/e2e-client.yaml b/.github/workflows/e2e-client.yaml new file mode 100644 index 0000000..152c5c4 --- /dev/null +++ b/.github/workflows/e2e-client.yaml @@ -0,0 +1,117 @@ +name: "E2E — Client (persistence)" + +on: + push: + paths: + - "network-protocol/e2e/**" + - "network-protocol/schemas/**" + pull_request: + paths: + - "network-protocol/e2e/**" + - "network-protocol/schemas/**" + workflow_dispatch: + inputs: + persistence_image: + description: "Persistence Docker image (e.g. harbor.example.com/dyingstar/persistence:latest)" + required: true + default: "harbor.example.com/dyingstar/persistence:latest" + scenario_filter: + description: "Optional scenario path filter (e.g. persistence/get-all-items)" + required: false + default: "persistence" + +jobs: + e2e-client: + name: Client scenarios + runs-on: ubuntu-latest + + env: + PERSISTENCE_IMAGE: ${{ github.event.inputs.persistence_image || 'harbor.example.com/dyingstar/persistence:latest' }} + SCENARIO_FILTER: ${{ github.event.inputs.scenario_filter || 'persistence' }} + NETWORK_PROTOCOL_SCHEMAS_DIR: ${{ github.workspace }}/network-protocol/schemas + # Used by client scenarios (${PERSISTENCE_WS_URL} in scenario.json) + PERSISTENCE_WS_URL: ws://localhost:9100/ws + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: network-protocol/e2e/package.json + + - name: Install e2e dependencies + working-directory: network-protocol/e2e + run: npm ci + + - name: Log in to Harbor + uses: docker/login-action@v3 + with: + registry: harbor.example.com + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_PASSWORD }} + + - name: Pull service images + run: docker pull "$PERSISTENCE_IMAGE" + + # ScyllaDB — required by the persistence service. + # We run it on host network so the persistence container can reach it at 127.0.0.1. + - name: Start ScyllaDB + run: | + docker run --rm --detach \ + --network host \ + --name scylladb-e2e \ + scylladb/scylla:6.2 \ + --smp 1 --memory 512M --developer-mode 1 + + - name: Wait for ScyllaDB to be ready + run: | + echo "Waiting for ScyllaDB..." + for i in $(seq 1 30); do + if docker exec scylladb-e2e nodetool status 2>/dev/null | grep -q "^UN"; then + echo "ScyllaDB is ready" + break + fi + echo " attempt $i/30 — not ready yet" + sleep 5 + done + docker exec scylladb-e2e nodetool status + + - name: Start persistence service + run: | + docker run --rm --detach \ + --network host \ + --name persistence-e2e \ + -e SCYLLA_NODES=127.0.0.1 \ + -e SCYLLA_KEYSPACE=dyingstar \ + -e WS_PORT=9100 \ + "$PERSISTENCE_IMAGE" + + - name: Wait for persistence to be ready + run: | + echo "Waiting for persistence WebSocket..." + for i in $(seq 1 20); do + if curl -sf http://localhost:9101/health > /dev/null 2>&1; then + echo "Persistence is ready" + break + fi + echo " attempt $i/20 — not ready yet" + sleep 3 + done + + - name: Run client scenarios + working-directory: network-protocol/e2e + run: SCENARIO_FILTER="$SCENARIO_FILTER" npm test + + - name: Show persistence logs on failure + if: failure() + run: docker logs persistence-e2e || true + + - name: Stop containers (always) + if: always() + run: | + docker stop persistence-e2e || true + docker stop scylladb-e2e || true diff --git a/.github/workflows/e2e-mock.yaml b/.github/workflows/e2e-mock.yaml new file mode 100644 index 0000000..159bbf8 --- /dev/null +++ b/.github/workflows/e2e-mock.yaml @@ -0,0 +1,76 @@ +name: "E2E — Mock server (Horizon)" + +# Run when anything in the e2e framework or schemas changes, or on manual trigger. +on: + push: + paths: + - "network-protocol/e2e/**" + - "network-protocol/schemas/**" + pull_request: + paths: + - "network-protocol/e2e/**" + - "network-protocol/schemas/**" + workflow_dispatch: + inputs: + horizon_image: + description: "Horizon Docker image to test (e.g. harbor.example.com/dyingstar/horizon:latest)" + required: true + default: "harbor.example.com/dyingstar/horizon:latest" + scenario_filter: + description: "Optional scenario path filter (e.g. horizon/startup-persistence)" + required: false + default: "horizon" + +jobs: + e2e-mock: + name: Mock-server scenarios + runs-on: ubuntu-latest + + env: + HORIZON_IMAGE: ${{ github.event.inputs.horizon_image || 'harbor.example.com/dyingstar/horizon:latest' }} + SCENARIO_FILTER: ${{ github.event.inputs.scenario_filter || 'horizon' }} + NETWORK_PROTOCOL_SCHEMAS_DIR: ${{ github.workspace }}/network-protocol/schemas + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: network-protocol/e2e/package.json + + - name: Install e2e dependencies + working-directory: network-protocol/e2e + run: npm ci + + - name: Log in to Harbor + uses: docker/login-action@v3 + with: + registry: harbor.example.com + username: ${{ secrets.HARBOR_USERNAME }} + password: ${{ secrets.HARBOR_PASSWORD }} + + - name: Pull Horizon image + run: docker pull "$HORIZON_IMAGE" + + # Start the TypeScript mock servers in the background, then launch Horizon. + # Both processes share the host network so Horizon connects to localhost:. + - name: Run mock-server scenarios + working-directory: network-protocol/e2e + run: | + # Start Horizon with the CI plugins.toml (redirects all service URLs to localhost) + docker run --rm --detach \ + --network host \ + --name horizon-e2e \ + -v "${{ github.workspace }}/network-protocol/e2e/ci/plugins.toml:/app/plugins.toml:ro" \ + "$HORIZON_IMAGE" + + # Run the Mocha tests (mock servers start inside the test process) + SCENARIO_FILTER="$SCENARIO_FILTER" npm test + + - name: Stop Horizon container (always) + if: always() + run: docker stop horizon-e2e || true diff --git a/e2e/ci/plugins.toml b/e2e/ci/plugins.toml new file mode 100644 index 0000000..71d30d2 --- /dev/null +++ b/e2e/ci/plugins.toml @@ -0,0 +1,57 @@ +# ============================================================================ +# CI override of plugins.toml for the e2e mock-server test job. +# +# All service URLs point to localhost so that the Horizon Docker container +# (run with --network host) connects to the TypeScript mock WebSocket servers +# started by the test runner on the same host. +# ============================================================================ + +[ds_game_server] +log_level = "info" +game_servers = ["ws://127.0.0.1:8980"] +servers_mode = "single" +split_rule = "fps:20" + +[ds_genericprops] +log_level = "info" + +[ds_player_authentication] +log_level = "info" + +[ds_props] +log_level = "info" + +[ds_services] +log_level = "info" +resources_dynamic_address = "127.0.0.1:9200" + +[ds_audio] +log_level = "info" + +[ds_bridge] +log_level = "debug" + +[[ds_bridge.services]] +name = "persistence" +url = "ws://127.0.0.1:9100/ws" +subscribe = [ + "plugin:genericprops:create_object", + "plugin:genericprops:create_object_from_gameserver", + "plugin:genericprops:update_object", + "plugin:genericprops:update_object_from_external", + "plugin:bridge_persistence:get_all_items", + "plugin:bridge_persistence:player_spawn", +] + +[[ds_bridge.services]] +name = "monitoring" +url = "ws://127.0.0.1:9300/ws" +subscribe = [ + "core:player_connected", + "core:player_disconnected", + "plugin:genericprops:create_object", + "plugin:genericprops:create_object_from_gameserver", + "plugin:ds_player_authentication:player_authenticated", + "plugin:ds_game_server:server_registered", + "plugin:ds_game_server:server_unregistered", +] diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 0000000..adeb570 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,1559 @@ +{ + "name": "network-protocol-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "network-protocol-e2e", + "version": "1.0.0", + "dependencies": { + "ajv": "^8.17.1", + "glob": "^11.0.0" + }, + "devDependencies": { + "@types/chai": "^5.0.0", + "@types/mocha": "^10.0.0", + "@types/node": "^22.0.0", + "@types/ws": "^8.0.0", + "chai": "^5.0.0", + "mocha": "^10.0.0", + "ts-node": "^10.9.0", + "typescript": "^5.0.0", + "ws": "^8.0.0" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mocha": { + "version": "10.0.10", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", + "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.20", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.20.tgz", + "integrity": "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "dev": true, + "license": "ISC" + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-yaml": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mocha": { + "version": "10.8.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", + "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.3", + "browser-stdout": "^1.3.1", + "chokidar": "^3.5.3", + "debug": "^4.3.5", + "diff": "^5.2.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^8.1.0", + "he": "^1.2.0", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^5.1.6", + "ms": "^2.1.3", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^6.5.1", + "yargs": "^16.2.0", + "yargs-parser": "^20.2.9", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/mocha/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/mocha/node_modules/brace-expansion": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/mocha/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mocha/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/workerpool": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", + "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 0000000..af2fb6c --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,25 @@ +{ + "name": "network-protocol-e2e", + "version": "1.0.0", + "private": true, + "description": "Scenario-based E2E WebSocket tests for DyingStar services", + "scripts": { + "test": "mocha -r ts-node/register 'test/**/*.ts'", + "test:scenario": "SCENARIO_FILTER=$SCENARIO mocha -r ts-node/register 'test/**/*.ts'" + }, + "devDependencies": { + "@types/chai": "^5.0.0", + "@types/mocha": "^10.0.0", + "@types/node": "^22.0.0", + "@types/ws": "^8.0.0", + "chai": "^5.0.0", + "mocha": "^10.0.0", + "ts-node": "^10.9.0", + "typescript": "^5.0.0", + "ws": "^8.0.0" + }, + "dependencies": { + "ajv": "^8.17.1", + "glob": "^11.0.0" + } +} diff --git a/e2e/scenarios/horizon/startup-persistence/scenario.json b/e2e/scenarios/horizon/startup-persistence/scenario.json new file mode 100644 index 0000000..541e9b9 --- /dev/null +++ b/e2e/scenarios/horizon/startup-persistence/scenario.json @@ -0,0 +1,31 @@ +{ + "name": "horizon-startup-persistence", + "mode": "mock-server", + "services": [ + { "name": "persistence", "port": 9100 }, + { "name": "resourcesdynamic", "port": 9200 } + ], + "sequence": [ + { + "step": 1, + "service": "persistence", + "action": "receive", + "file": "step-01-receive-handshake.json", + "matchFields": ["event_type", "name"] + }, + { + "step": 2, + "service": "persistence", + "action": "receive", + "file": "step-02-receive-get-all-items.json", + "schema": "bridge-persistence/get_all_items.schema.json", + "matchFields": ["event_type", "name"] + }, + { + "step": 3, + "service": "persistence", + "action": "send", + "file": "step-03-send-items-end.json" + } + ] +} diff --git a/e2e/scenarios/horizon/startup-persistence/step-01-receive-handshake.json b/e2e/scenarios/horizon/startup-persistence/step-01-receive-handshake.json new file mode 100644 index 0000000..b791f9d --- /dev/null +++ b/e2e/scenarios/horizon/startup-persistence/step-01-receive-handshake.json @@ -0,0 +1,6 @@ +{ + "event_type": "bridge", + "namespace": null, + "name": "connected", + "payload": { "is_reconnection": false } +} diff --git a/e2e/scenarios/horizon/startup-persistence/step-02-receive-get-all-items.json b/e2e/scenarios/horizon/startup-persistence/step-02-receive-get-all-items.json new file mode 100644 index 0000000..444bd0c --- /dev/null +++ b/e2e/scenarios/horizon/startup-persistence/step-02-receive-get-all-items.json @@ -0,0 +1,6 @@ +{ + "event_type": "core", + "namespace": null, + "name": "get_all_items", + "payload": {} +} diff --git a/e2e/scenarios/horizon/startup-persistence/step-03-send-items-end.json b/e2e/scenarios/horizon/startup-persistence/step-03-send-items-end.json new file mode 100644 index 0000000..b91aa4f --- /dev/null +++ b/e2e/scenarios/horizon/startup-persistence/step-03-send-items-end.json @@ -0,0 +1,6 @@ +{ + "event_type": "core", + "namespace": null, + "name": "items_end", + "payload": {} +} diff --git a/e2e/scenarios/persistence/get-all-items/scenario.json b/e2e/scenarios/persistence/get-all-items/scenario.json new file mode 100644 index 0000000..8a1a91f --- /dev/null +++ b/e2e/scenarios/persistence/get-all-items/scenario.json @@ -0,0 +1,19 @@ +{ + "name": "persistence-get-all-items", + "mode": "client", + "url": "${PERSISTENCE_WS_URL}", + "sequence": [ + { + "step": 1, + "action": "send", + "file": "step-01-send-get-all-items.json" + }, + { + "step": 2, + "action": "receive-stream", + "schema": "bridge-persistence/response.items_chunk.schema.json", + "until": "step-02-terminal-items-end.json", + "terminalSchema": "bridge-persistence/response.items_end.schema.json" + } + ] +} diff --git a/e2e/scenarios/persistence/get-all-items/step-01-send-get-all-items.json b/e2e/scenarios/persistence/get-all-items/step-01-send-get-all-items.json new file mode 100644 index 0000000..444bd0c --- /dev/null +++ b/e2e/scenarios/persistence/get-all-items/step-01-send-get-all-items.json @@ -0,0 +1,6 @@ +{ + "event_type": "core", + "namespace": null, + "name": "get_all_items", + "payload": {} +} diff --git a/e2e/scenarios/persistence/get-all-items/step-02-terminal-items-end.json b/e2e/scenarios/persistence/get-all-items/step-02-terminal-items-end.json new file mode 100644 index 0000000..b91aa4f --- /dev/null +++ b/e2e/scenarios/persistence/get-all-items/step-02-terminal-items-end.json @@ -0,0 +1,6 @@ +{ + "event_type": "core", + "namespace": null, + "name": "items_end", + "payload": {} +} diff --git a/e2e/src/client.ts b/e2e/src/client.ts new file mode 100644 index 0000000..045ed6a --- /dev/null +++ b/e2e/src/client.ts @@ -0,0 +1,156 @@ +import * as fs from "fs"; +import * as path from "path"; +import WebSocket from "ws"; +import { ClientScenario, Step } from "./types"; +import { assertSchema } from "./validator"; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function loadStepFile(scenarioDir: string, filename: string): unknown { + const full = path.join(scenarioDir, filename); + if (!fs.existsSync(full)) { + throw new Error(`Step file not found: ${full}`); + } + return JSON.parse(fs.readFileSync(full, "utf8")); +} + +/** Expand ${VAR_NAME} placeholders using process.env. */ +function expandEnvVars(str: string): string { + return str.replace(/\$\{([^}]+)\}/g, (_, name: string) => { + const value = process.env[name]; + if (value === undefined) { + throw new Error(`Environment variable "${name}" is not set (referenced in scenario URL)`); + } + return value; + }); +} + +function subsetMatch(expected: Record, actual: unknown, fields: string[]): void { + if (typeof actual !== "object" || actual === null) { + throw new Error(`Expected an object but received: ${JSON.stringify(actual)}`); + } + const actualObj = actual as Record; + for (const field of fields) { + const exp = JSON.stringify(expected[field]); + const got = JSON.stringify(actualObj[field]); + if (exp !== got) { + throw new Error( + `Field mismatch on "${field}":\n expected: ${exp}\n received: ${got}` + ); + } + } +} + +function connect(url: string): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + ws.once("open", () => resolve(ws)); + ws.once("error", reject); + }); +} + +function waitForMessage(ws: WebSocket, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + ws.off("message", onMsg); + reject(new Error(`Timeout (${timeoutMs}ms): no message received`)); + }, timeoutMs); + + const onMsg = (raw: WebSocket.RawData) => { + clearTimeout(timer); + ws.off("message", onMsg); + let parsed: unknown; + try { + parsed = JSON.parse(raw.toString()); + } catch { + reject(new Error(`Received non-JSON message: ${raw.toString()}`)); + return; + } + resolve(parsed); + }; + + ws.on("message", onMsg); + }); +} + +// ── Step runner ─────────────────────────────────────────────────────────────── + +const RECEIVE_TIMEOUT_MS = 10_000; + +async function runStep(step: Step, ws: WebSocket, scenarioDir: string): Promise { + const stepLabel = `step ${step.step}`; + + if (step.action === "send") { + if (!step.file) throw new Error(`${stepLabel}: "file" is required for action "send"`); + const payload = loadStepFile(scenarioDir, step.file); + ws.send(JSON.stringify(payload)); + return; + } + + if (step.action === "receive") { + const received = await waitForMessage(ws, RECEIVE_TIMEOUT_MS); + + if (step.schema) { + assertSchema(step.schema, received, stepLabel); + } + + if (step.file && step.matchFields && step.matchFields.length > 0) { + const expected = loadStepFile(scenarioDir, step.file) as Record; + subsetMatch(expected, received, step.matchFields); + } + return; + } + + if (step.action === "receive-stream") { + if (!step.until) throw new Error(`${stepLabel}: "until" is required for action "receive-stream"`); + const terminal = loadStepFile(scenarioDir, step.until) as Record; + const terminalFields = Object.keys(terminal); + + while (true) { + const received = await waitForMessage(ws, RECEIVE_TIMEOUT_MS); + + const isTerminal = terminalFields.every( + (f) => + JSON.stringify((received as Record)[f]) === + JSON.stringify(terminal[f]) + ); + + if (!isTerminal) { + if (step.schema) { + assertSchema(step.schema, received, `${stepLabel} (stream message)`); + } + } else { + if (step.terminalSchema) { + assertSchema(step.terminalSchema, received, `${stepLabel} (terminal message)`); + } + break; + } + } + return; + } + + throw new Error(`${stepLabel}: unknown action "${(step as Step).action}"`); +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Run a client scenario: + * 1. Connect to the service URL (env-var-interpolated). + * 2. Execute each step in sequence. + * 3. Close the connection. + */ +export async function runClient( + scenario: ClientScenario, + scenarioDir: string +): Promise { + const url = expandEnvVars(scenario.url); + const ws = await connect(url); + try { + for (const step of scenario.sequence) { + await runStep(step, ws, scenarioDir); + } + } finally { + if (ws.readyState === WebSocket.OPEN) ws.close(); + } +} diff --git a/e2e/src/mock-server.ts b/e2e/src/mock-server.ts new file mode 100644 index 0000000..61356b2 --- /dev/null +++ b/e2e/src/mock-server.ts @@ -0,0 +1,218 @@ +import * as http from "http"; +import * as fs from "fs"; +import * as path from "path"; +import WebSocket, { WebSocketServer } from "ws"; +import { MockServerScenario, Step } from "./types"; +import { assertSchema } from "./validator"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface ServiceState { + server: WebSocketServer; + httpServer: http.Server; + /** Resolves when the first client connects. */ + connected: Promise; + socket: WebSocket | null; +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function loadStepFile(scenarioDir: string, filename: string): unknown { + const full = path.join(scenarioDir, filename); + if (!fs.existsSync(full)) { + throw new Error(`Step file not found: ${full}`); + } + return JSON.parse(fs.readFileSync(full, "utf8")); +} + +function subsetMatch(expected: Record, actual: unknown, fields: string[]): void { + if (typeof actual !== "object" || actual === null) { + throw new Error(`Expected an object but received: ${JSON.stringify(actual)}`); + } + const actualObj = actual as Record; + for (const field of fields) { + const exp = JSON.stringify(expected[field]); + const got = JSON.stringify(actualObj[field]); + if (exp !== got) { + throw new Error( + `Field mismatch on "${field}":\n expected: ${exp}\n received: ${got}` + ); + } + } +} + +function waitForMessage(socket: WebSocket, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + socket.off("message", onMsg); + reject(new Error(`Timeout (${timeoutMs}ms): no message received`)); + }, timeoutMs); + + const onMsg = (raw: WebSocket.RawData) => { + clearTimeout(timer); + socket.off("message", onMsg); + let parsed: unknown; + try { + parsed = JSON.parse(raw.toString()); + } catch { + reject(new Error(`Received non-JSON message: ${raw.toString()}`)); + return; + } + resolve(parsed); + }; + + socket.on("message", onMsg); + }); +} + +// ── Mock server ─────────────────────────────────────────────────────────────── + +/** + * Start one WebSocket server per service defined in the scenario. + * Returns a map of service-name → ServiceState. + */ +function startServers(scenario: MockServerScenario): Map { + const services = new Map(); + + for (const svc of scenario.services) { + const httpServer = http.createServer(); + const wss = new WebSocketServer({ server: httpServer }); + + let resolveConnected!: (ws: WebSocket) => void; + const connected = new Promise((res) => { + resolveConnected = res; + }); + + const state: ServiceState = { + server: wss, + httpServer, + connected, + socket: null, + }; + + wss.on("connection", (ws) => { + state.socket = ws; + resolveConnected(ws); + }); + + httpServer.listen(svc.port); + services.set(svc.name, state); + console.log(` [mock] ${svc.name} listening on port ${svc.port}`); + } + + return services; +} + +function stopServers(services: Map): Promise { + const closes: Promise[] = []; + + for (const [, state] of services) { + if (state.socket && state.socket.readyState === WebSocket.OPEN) { + state.socket.close(); + } + closes.push( + new Promise((res) => state.httpServer.close(() => res())) + ); + } + + return Promise.all(closes); +} + +// ── Step runner ─────────────────────────────────────────────────────────────── + +const RECEIVE_TIMEOUT_MS = 10_000; + +async function runStep( + step: Step, + services: Map, + scenarioDir: string +): Promise { + const stepLabel = `step ${step.step}`; + + // resolve which socket to use + const serviceName = step.service; + if (!serviceName) { + throw new Error(`${stepLabel}: "service" field is required in mock-server mode`); + } + const serviceState = services.get(serviceName); + if (!serviceState) { + throw new Error(`${stepLabel}: unknown service "${serviceName}"`); + } + + // wait for the remote end to be connected before any step that needs a socket + const socket = await serviceState.connected; + + if (step.action === "send") { + if (!step.file) throw new Error(`${stepLabel}: "file" is required for action "send"`); + const payload = loadStepFile(scenarioDir, step.file); + socket.send(JSON.stringify(payload)); + return; + } + + if (step.action === "receive") { + const received = await waitForMessage(socket, RECEIVE_TIMEOUT_MS); + + if (step.schema) { + assertSchema(step.schema, received, stepLabel); + } + + if (step.file && step.matchFields && step.matchFields.length > 0) { + const expected = loadStepFile(scenarioDir, step.file) as Record; + subsetMatch(expected, received, step.matchFields); + } + return; + } + + if (step.action === "receive-stream") { + if (!step.until) throw new Error(`${stepLabel}: "until" is required for action "receive-stream"`); + const terminal = loadStepFile(scenarioDir, step.until) as Record; + const terminalFields = Object.keys(terminal); + + // Collect messages until one fully matches the terminal file fields + while (true) { + const received = await waitForMessage(socket, RECEIVE_TIMEOUT_MS); + + const isTerminal = terminalFields.every( + (f) => + JSON.stringify((received as Record)[f]) === + JSON.stringify(terminal[f]) + ); + + if (!isTerminal) { + if (step.schema) { + assertSchema(step.schema, received, `${stepLabel} (stream message)`); + } + } else { + if (step.terminalSchema) { + assertSchema(step.terminalSchema, received, `${stepLabel} (terminal message)`); + } + break; + } + } + return; + } + + throw new Error(`${stepLabel}: unknown action "${(step as Step).action}"`); +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * Run a mock-server scenario: + * 1. Start one WS server per service. + * 2. Execute each step in sequence. + * 3. Stop all servers. + */ +export async function runMockServer( + scenario: MockServerScenario, + scenarioDir: string +): Promise { + const services = startServers(scenario); + try { + for (const step of scenario.sequence) { + await runStep(step, services, scenarioDir); + } + } finally { + await stopServers(services); + } +} diff --git a/e2e/src/runner.ts b/e2e/src/runner.ts new file mode 100644 index 0000000..e87d9ba --- /dev/null +++ b/e2e/src/runner.ts @@ -0,0 +1,53 @@ +import * as fs from "fs"; +import * as path from "path"; +import { ScenarioManifest } from "./types"; +import { runMockServer } from "./mock-server"; +import { runClient } from "./client"; + +/** + * Load and parse a scenario.json file. + * Throws if the file is missing or malformed. + */ +export function loadScenario(scenarioJsonPath: string): ScenarioManifest { + if (!fs.existsSync(scenarioJsonPath)) { + throw new Error(`Scenario file not found: ${scenarioJsonPath}`); + } + + const raw = fs.readFileSync(scenarioJsonPath, "utf8"); + let manifest: ScenarioManifest; + try { + manifest = JSON.parse(raw) as ScenarioManifest; + } catch (err) { + throw new Error( + `Invalid JSON in ${scenarioJsonPath}: ${(err as Error).message}` + ); + } + + if (!manifest.mode) { + throw new Error(`${scenarioJsonPath}: missing required field "mode"`); + } + if (manifest.mode !== "mock-server" && manifest.mode !== "client") { + throw new Error( + `${scenarioJsonPath}: unknown mode "${(manifest as ScenarioManifest).mode}". Expected "mock-server" or "client".` + ); + } + + return manifest; +} + +/** + * Run a scenario from its scenario.json path. + * Dispatches to the mock-server or client runner depending on `mode`. + */ +export async function run(scenarioJsonPath: string): Promise { + const scenarioDir = path.dirname(scenarioJsonPath); + const manifest = loadScenario(scenarioJsonPath); + + console.log(`\nRunning scenario: ${manifest.name} [${manifest.mode}]`); + + if (manifest.mode === "mock-server") { + await runMockServer(manifest, scenarioDir); + } else { + await runClient(manifest, scenarioDir); + } +} diff --git a/e2e/src/types.ts b/e2e/src/types.ts new file mode 100644 index 0000000..41fcd76 --- /dev/null +++ b/e2e/src/types.ts @@ -0,0 +1,60 @@ +// ── Scenario manifest ──────────────────────────────────────────────────────── + +export type StepAction = + | "send" + | "receive" + | "receive-stream"; + +/** + * A single step in a scenario sequence. + * + * Common fields: + * step — 1-based position in the sequence (informational) + * action — what to do at this step + * schema — relative path from SCHEMAS_DIR to validate the message against + * file — JSON file whose content is sent (send) or matched (receive) + * matchFields — subset of top-level fields to compare on receive (ignores dynamic values like UUIDs) + * + * receive-stream specific: + * until — filename of the terminal message that ends the stream; + * every intermediate message is validated against `schema` + * + * mock-server specific: + * service — name of the mocked service this step belongs to + */ +export interface Step { + step: number; + action: StepAction; + service?: string; // mock-server mode only + file?: string; // path to JSON payload file (relative to scenario dir) + schema?: string; // schema path relative to SCHEMAS_DIR + matchFields?: string[]; // fields to compare on receive (subset match) + until?: string; // receive-stream: terminal message filename + terminalSchema?: string; // receive-stream: schema to validate the terminal message against +} + +/** Service entry for mock-server mode. */ +export interface ServiceConfig { + name: string; + port: number; +} + +/** The parsed contents of a scenario.json manifest. */ +export type ScenarioManifest = + | MockServerScenario + | ClientScenario; + +export interface MockServerScenario { + name: string; + mode: "mock-server"; + services: ServiceConfig[]; + sequence: Step[]; +} + +export interface ClientScenario { + name: string; + mode: "client"; + /** WebSocket URL; supports ${ENV_VAR} interpolation. */ + url: string; + sequence: Step[]; +} diff --git a/e2e/src/validator.ts b/e2e/src/validator.ts new file mode 100644 index 0000000..010c4fb --- /dev/null +++ b/e2e/src/validator.ts @@ -0,0 +1,52 @@ +import Ajv from "ajv"; +import * as fs from "fs"; +import * as path from "path"; + +const SCHEMAS_DIR = + process.env.NETWORK_PROTOCOL_SCHEMAS_DIR ?? + path.resolve(__dirname, "../../schemas"); + +const ajv = new Ajv({ strict: false, allErrors: true }); + +const cache = new Map>(); + +/** + * Load and compile a JSON Schema by its path relative to SCHEMAS_DIR. + * Compiled validators are cached so schemas are only parsed once. + */ +export function loadSchema(schemaRelPath: string): ReturnType { + const cached = cache.get(schemaRelPath); + if (cached) return cached; + + const full = path.join(SCHEMAS_DIR, schemaRelPath); + if (!fs.existsSync(full)) { + throw new Error(`Schema file not found: ${full}`); + } + + const raw = fs.readFileSync(full, "utf8"); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error(`Invalid JSON in schema file ${schemaRelPath}: ${(err as Error).message}`); + } + + const validate = ajv.compile(parsed as object); + cache.set(schemaRelPath, validate); + return validate; +} + +/** + * Validate `data` against the schema at `schemaRelPath`. + * Throws an Error with AJV details if validation fails. + */ +export function assertSchema(schemaRelPath: string, data: unknown, label: string): void { + const validate = loadSchema(schemaRelPath); + const ok = validate(data); + if (!ok) { + throw new Error( + `Schema validation failed for ${label} (schema: ${schemaRelPath}):\n` + + JSON.stringify(validate.errors, null, 2) + ); + } +} diff --git a/e2e/test/scenarios.test.ts b/e2e/test/scenarios.test.ts new file mode 100644 index 0000000..9892411 --- /dev/null +++ b/e2e/test/scenarios.test.ts @@ -0,0 +1,44 @@ +import * as path from "path"; +import { globSync } from "glob"; +import { run } from "../src/runner"; + +// ── Scenario discovery ──────────────────────────────────────────────────────── + +const SCENARIOS_DIR = path.resolve(__dirname, "../scenarios"); + +// Optional filter: SCENARIO_FILTER=horizon/startup-persistence npm test +const filter = process.env.SCENARIO_FILTER; + +const scenarioFiles = globSync("**/scenario.json", { cwd: SCENARIOS_DIR, absolute: true }) + .filter((f) => { + if (!filter) return true; + // Match if the scenario path contains the filter string + return f.includes(filter.replace(/\//g, path.sep)); + }) + .sort(); + +if (scenarioFiles.length === 0) { + throw new Error( + filter + ? `No scenarios found matching filter: "${filter}"` + : `No scenario.json files found under ${SCENARIOS_DIR}` + ); +} + +// ── Mocha suite ─────────────────────────────────────────────────────────────── + +describe("E2E scenarios", function () { + // Allow generous timeout: mock-server scenarios wait for Docker services to start. + this.timeout(60_000); + + for (const scenarioFile of scenarioFiles) { + // Derive a human-readable name from the path: + // .../scenarios/horizon/startup-persistence/scenario.json → "horizon/startup-persistence" + const rel = path.relative(SCENARIOS_DIR, path.dirname(scenarioFile)); + const label = rel.replace(/\\/g, "/"); // normalise on Windows too + + it(label, async function () { + await run(scenarioFile); + }); + } +}); diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json new file mode 100644 index 0000000..62fc509 --- /dev/null +++ b/e2e/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "outDir": "dist" + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/schemas/e2e-scenario.schema.json b/schemas/e2e-scenario.schema.json new file mode 100644 index 0000000..b9b09f8 --- /dev/null +++ b/schemas/e2e-scenario.schema.json @@ -0,0 +1,90 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "E2E scenario manifest", + "description": "Schema for scenario.json files used by the network-protocol e2e test framework.", + "type": "object", + "required": ["name", "mode", "sequence"], + "properties": { + "name": { + "type": "string", + "description": "Human-readable scenario identifier" + }, + "mode": { + "type": "string", + "enum": ["mock-server", "client"], + "description": "mock-server: TypeScript acts as the external service; client: TypeScript connects to a running service" + }, + "services": { + "type": "array", + "description": "Required for mock-server mode. One entry per service to impersonate.", + "items": { + "type": "object", + "required": ["name", "port"], + "properties": { + "name": { "type": "string" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 } + }, + "additionalProperties": false + } + }, + "url": { + "type": "string", + "description": "Required for client mode. WebSocket URL of the target service. Supports ${ENV_VAR} interpolation." + }, + "sequence": { + "type": "array", + "description": "Ordered list of steps to execute.", + "minItems": 1, + "items": { "$ref": "#/$defs/step" } + } + }, + "if": { "properties": { "mode": { "const": "mock-server" } } }, + "then": { "required": ["services"] }, + "else": { "required": ["url"] }, + "$defs": { + "step": { + "type": "object", + "required": ["step", "action"], + "properties": { + "step": { + "type": "integer", + "minimum": 1, + "description": "1-based step number (informational)" + }, + "action": { + "type": "string", + "enum": ["send", "receive", "receive-stream"], + "description": "send: send file JSON to the connected peer; receive: wait for one message and validate it; receive-stream: collect messages until the terminal message arrives" + }, + "service": { + "type": "string", + "description": "mock-server mode only. Name of the service whose socket to use." + }, + "file": { + "type": "string", + "description": "Filename of the JSON payload (relative to the scenario directory). Required for send; used for matchFields on receive." + }, + "schema": { + "type": "string", + "description": "Path to the JSON schema to validate the message against, relative to SCHEMAS_DIR." + }, + "matchFields": { + "type": "array", + "items": { "type": "string" }, + "description": "Subset of top-level fields to compare on receive (ignores dynamic values like UUIDs/timestamps)." + }, + "until": { + "type": "string", + "description": "receive-stream only. Filename of the terminal message that ends the stream." + }, + "terminalSchema": { + "type": "string", + "description": "receive-stream only. Schema path (relative to SCHEMAS_DIR) to validate the terminal message against." + } + }, + "additionalProperties": false, + "if": { "properties": { "action": { "const": "send" } } }, + "then": { "required": ["file"] } + } + } +} diff --git a/validate.js b/validate.js index 91df1f5..c327d40 100644 --- a/validate.js +++ b/validate.js @@ -2,13 +2,16 @@ /** * validate.js — CI self-check for network-protocol * - * Finds every *.schema.json file under schemas/ and verifies that: - * 1. The file is valid JSON. - * 2. The parsed object is a valid JSON Schema (draft-07) according to ajv's - * meta-schema check. + * 1. Finds every *.schema.json file under schemas/ and verifies that: + * a. The file is valid JSON. + * b. The parsed object is a valid JSON Schema (draft-07) according to ajv's + * meta-schema check. * - * Exit code 0 = all schemas valid. - * Exit code 1 = one or more schemas failed. + * 2. Finds every scenario.json file under e2e/scenarios/ and validates it + * against schemas/e2e-scenario.schema.json. + * + * Exit code 0 = all checks pass. + * Exit code 1 = one or more checks failed. */ const fs = require('fs'); @@ -17,15 +20,18 @@ const Ajv = require('ajv'); const ajv = new Ajv({ strict: false, allErrors: true }); -const schemasDir = path.join(__dirname, 'schemas'); +const schemasDir = path.join(__dirname, 'schemas'); +const scenariosDir = path.join(__dirname, 'e2e', 'scenarios'); let failed = 0; let total = 0; -function walk(dir) { +// ── 1. Validate all *.schema.json files ────────────────────────────────────── + +function walkSchemas(dir) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { - walk(full); + walkSchemas(full); } else if (entry.name.endsWith('.schema.json')) { total++; const rel = path.relative(__dirname, full); @@ -50,9 +56,52 @@ function walk(dir) { } } -walk(schemasDir); +walkSchemas(schemasDir); + +// ── 2. Validate scenario.json files against e2e-scenario.schema.json ───────── + +const scenarioSchemaPath = path.join(schemasDir, 'e2e-scenario.schema.json'); + +if (fs.existsSync(scenarioSchemaPath) && fs.existsSync(scenariosDir)) { + const scenarioSchema = JSON.parse(fs.readFileSync(scenarioSchemaPath, 'utf8')); + const validateScenario = ajv.compile(scenarioSchema); + + function walkScenarios(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walkScenarios(full); + } else if (entry.name === 'scenario.json') { + total++; + const rel = path.relative(__dirname, full); + let parsed; + try { + parsed = JSON.parse(fs.readFileSync(full, 'utf8')); + } catch (err) { + console.error(`FAIL ${rel}\n Invalid JSON: ${err.message}`); + failed++; + continue; + } + const ok = validateScenario(parsed); + if (!ok) { + console.error( + `FAIL ${rel}\n Schema violations:\n` + + JSON.stringify(validateScenario.errors, null, 2) + ); + failed++; + } else { + console.log(`ok ${rel}`); + } + } + } + } + + walkScenarios(scenariosDir); +} + +// ── Summary ─────────────────────────────────────────────────────────────────── -console.log(`\n${total - failed}/${total} schemas valid`); +console.log(`\n${total - failed}/${total} checks passed`); if (failed > 0) { process.exit(1); }