From 47f6cbf2d15f5b92bd2ec48ba4d15ef21153c15d Mon Sep 17 00:00:00 2001 From: ajet Date: Sat, 19 Sep 2026 19:17:22 -0900 Subject: [PATCH] add ui tests to ci --- .dockerignore | 6 +- .gitea/workflows/ci.yml | 8 +- Dockerfile | 14 +- README.md | 31 +- script/coverage | 5 +- script/docker-ui-coverage | 20 + script/docker-ui-test | 8 + script/lib/docker.sh | 6 + script/test | 3 +- script/ui-coverage | 8 + script/ui-test | 5 +- ui/.c8rc.json | 7 + ui/package-lock.json | 1165 +++++++++++++++++++++++++++++++ ui/package.json | 6 +- ui/shadow-cljs.edn | 7 +- ui/test/dom-setup.cjs | 66 ++ ui/test/tbd/ui/core_test.cljs | 68 ++ ui/test/tbd/ui/events_test.cljs | 323 +++++++++ ui/test/tbd/ui/subs_test.cljs | 55 ++ ui/test/tbd/ui/test_util.cljs | 220 ++++++ ui/test/tbd/ui/views_test.cljs | 381 ++++++++++ ui/test/tbd/ui/webmcp_test.cljs | 98 +-- 22 files changed, 2426 insertions(+), 84 deletions(-) create mode 100755 script/docker-ui-coverage create mode 100755 script/docker-ui-test create mode 100755 script/ui-coverage create mode 100644 ui/.c8rc.json create mode 100644 ui/test/dom-setup.cjs create mode 100644 ui/test/tbd/ui/core_test.cljs create mode 100644 ui/test/tbd/ui/events_test.cljs create mode 100644 ui/test/tbd/ui/subs_test.cljs create mode 100644 ui/test/tbd/ui/test_util.cljs create mode 100644 ui/test/tbd/ui/views_test.cljs diff --git a/.dockerignore b/.dockerignore index b9b0f9d..1a4cd2c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,8 @@ -target +**/target .cpcache .git -node_modules -.shadow-cljs +**/node_modules +**/.shadow-cljs resources/public/js .env data diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6c85e52..4a45627 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -24,8 +24,12 @@ jobs: docker compose version docker buildx version - # Each suite runs once, under Cloverage; the merged report is the dockerized - # equivalent of script/coverage. + # Each suite runs once, under a coverage tool. The Clojure reports (Cloverage) are merged + # below; the UI's (c8) stands on its own. Together this is the dockerized equivalent of + # script/coverage. + - name: UI tests (coverage) + run: script/docker-ui-coverage + - name: Unit tests (coverage) run: script/docker-unit-coverage diff --git a/Dockerfile b/Dockerfile index 82352bf..842d1dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,8 @@ # to ../resources/public/js (see ui/shadow-cljs.edn), i.e. /build/resources/public/js here. # trixie, not bookworm: its default-jre is Java 21, which shadow-cljs's Closure Compiler # requires (bookworm's is 17 -> UnsupportedClassVersionError). -FROM node:22-trixie-slim AS ui +# ui-base holds what the UI's release build and its tests share: the toolchain and the deps. +FROM node:22-trixie-slim AS ui-base RUN apt-get update \ && apt-get install -y --no-install-recommends default-jre-headless \ @@ -14,9 +15,20 @@ RUN npm ci # Resolve the ClojureScript deps in their own layer so source edits don't re-download them. COPY ui/shadow-cljs.edn ./ RUN npx shadow-cljs classpath > /dev/null + +FROM ui-base AS ui COPY ui/src ./src RUN npx shadow-cljs release app +# The UI's tests, not part of the server image: `docker build --target ui-test` +# (script/docker-ui-test, script/docker-ui-coverage). Running the container runs the tests; +# `npm run coverage` (script/docker-ui-coverage) also writes the report to /build/target/coverage/ui. +FROM ui-base AS ui-test +COPY ui/.c8rc.json ./ +COPY ui/src ./src +COPY ui/test ./test +CMD ["npm", "test"] + # Stage 2: the server. # JDK 21+ is required by the MCP server (plumcp runs on virtual threads). FROM clojure:temurin-21-tools-deps diff --git a/README.md b/README.md index 720f157..8383111 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ dockerized integration tests, and joint line coverage. ## Requirements - Docker, for the integration tests and the `script/docker-*` tasks (all CI needs) -- For running tasks natively (optional): [Clojure CLI](https://clojure.org/guides/install_clojure) (tools.deps) with JDK 21+, [Node](https://nodejs.org/) 20+ (with a JDK) for the UI build, and [Babashka](https://babashka.org/) for `script/coverage-report` +- For running tasks natively (optional): [Clojure CLI](https://clojure.org/guides/install_clojure) (tools.deps) with JDK 21+, [Node](https://nodejs.org/) 20+ (22.22+ for the UI tests; with a JDK) for the UI build, and [Babashka](https://babashka.org/) for `script/coverage-report` ## Running @@ -224,7 +224,8 @@ directory at `/js`, and serves `resources/public/index.html`, the shell that mou | `script/ui-build` | Optimized release build into `resources/public/js` | | `script/ui-watch` | Rebuilds on every change, with hot reload; run `script/server` alongside and refresh the page | -The Docker image builds the UI in its own stage, so `docker build` needs no local Node. +The Docker image builds the UI in its own stage, so `docker build` needs no local Node. The UI's tests run +in another stage of the same Dockerfile (`--target ui-test`), which the server image doesn't include. ### Browser MCP tools (WebMCP) @@ -271,23 +272,35 @@ script/repl | Task | What it does | |------|--------------| | `script/unit-test` | Runs the Clojure tests in `test/` | -| `script/ui-test` | Runs the ClojureScript tests in `ui/test/` under Node (needs Node and a JDK; CI doesn't run it yet) | +| `script/ui-test` | Runs the ClojureScript tests in `ui/test/` under Node, in a [jsdom](https://github.com/jsdom/jsdom) window (needs Node and a JDK) | | `script/integration-test` | Builds the app image, starts it on a random port, runs the [integration suite](integration-test/README.md) against it, then tears it down | -| `script/test` | Unit tests, then integration tests (stops at the first failure) | +| `script/test` | UI tests, unit tests, then integration tests (stops at the first failure) | | `script/docker-unit-test` | Same as `script/unit-test`, but inside the app image (Docker only) | +| `script/docker-ui-test` | Same as `script/ui-test`, but inside a Node + JDK image (Docker only) | + +The UI tests render the real app in jsdom against a fake server and drive it like a user (type, submit, +click), next to plain tests of the re-frame events and subscriptions, the game rules and the WebMCP tools +(`ui/test/tbd/ui/`; the shared fakes are in `test_util.cljs`). `ui/test/dom-setup.cjs` gives them a +`window` and the real WebMCP library, and fails a run that hangs, rejects a promise nobody handles, or ends +without a result (a test that never calls `done` would otherwise pass or stall silently). `npm test` in +`ui/` preloads it, so use that (or the scripts) rather than running the build's output directly. ### Coverage -Line coverage via [Cloverage](https://github.com/cloverage/cloverage): +Line coverage of the Clojure code via [Cloverage](https://github.com/cloverage/cloverage), and of the UI via +[c8](https://github.com/bcoe/c8): | Task | Output | |------|--------| | `script/unit-coverage` | `target/coverage/unit/` | | `script/integration-coverage` | `target/coverage/integration/` | -| `script/coverage` | Runs both, then writes the merged report to `target/coverage/merged/` | -| `script/docker-unit-coverage`, `script/docker-coverage-report` | Dockerized versions of the unit run and the merge step, so `script/docker-unit-coverage`, `script/integration-coverage`, `script/docker-coverage-report` is `script/coverage` with no local Clojure or Babashka (this is what CI runs) | +| `script/ui-coverage` | `target/coverage/ui/` (V8 coverage of the compiled UI, mapped back to `ui/src/**/*.cljs` through the source maps) | +| `script/coverage` | Runs all three, then writes the merged Clojure report to `target/coverage/merged/` (the UI's stays separate) | +| `script/docker-unit-coverage`, `script/docker-ui-coverage`, `script/docker-coverage-report` | Dockerized versions of the unit run, the UI run and the merge step, so `script/docker-ui-coverage`, `script/docker-unit-coverage`, `script/integration-coverage`, `script/docker-coverage-report` is `script/coverage` with no local Clojure, Node or Babashka (this is what CI runs) | -Each report directory has an `index.html` and an `lcov.info`. See +Each report directory has an `index.html` and an `lcov.info`. The UI's numbers are approximate where one +ClojureScript form compiles to several JavaScript blocks (a `case` or `cond`, say): a line can show as +uncovered when only part of the form ran. See [integration-test/README.md](integration-test/README.md) for how integration coverage is collected. @@ -310,7 +323,7 @@ ui/ ClojureScript re-frame app (shadow-cljs project); ui/src/t resources/public/ index.html (the shell served at /) and js/ (compiled UI output, git-ignored) test/ Unit tests integration-test/ Dockerized integration tests and coverage harness -script/ Bash entry points (server, ui-build, test, coverage, repl, ...) and coverage helpers +script/ Bash entry points (server, ui-build, ui-test, test, coverage, repl, ...) and coverage helpers ``` To add a new group, create `tbd..routes` with a `routes` fn that returns a reitit route diff --git a/script/coverage b/script/coverage index cc991e8..0dbccc3 100755 --- a/script/coverage +++ b/script/coverage @@ -1,9 +1,10 @@ #!/usr/bin/env bash -# Run the full suite (unit + integration) with line coverage and write the joint -# report to target/coverage/merged. +# Run the full suite (UI + unit + integration) with line coverage. The Clojure suites' +# joint report goes to target/coverage/merged; the UI's is separate (target/coverage/ui). set -euo pipefail cd "$(dirname "$0")/.." +script/ui-coverage script/unit-coverage script/integration-coverage script/coverage-report diff --git a/script/docker-ui-coverage b/script/docker-ui-coverage new file mode 100755 index 0000000..57e0dcd --- /dev/null +++ b/script/docker-ui-coverage @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Run the UI's tests under c8 inside a Node + JDK image; needs only Docker. The report is +# copied out with `docker cp` (not a bind mount) so it works when this itself runs in a +# job container. Output: target/coverage/ui. +set -euo pipefail +cd "$(dirname "$0")/.." +source script/lib/docker.sh + +build_ui_test_image + +id=$(docker create --security-opt apparmor=unconfined tbd-ui-test npm run coverage) +trap 'docker rm -f "$id" >/dev/null' EXIT + +status=0 +docker start -a "$id" || status=$? + +rm -rf target/coverage/ui +mkdir -p target/coverage +docker cp "$id:/build/target/coverage/ui" target/coverage/ || echo "[test] WARNING: no UI coverage report was produced" +exit "$status" diff --git a/script/docker-ui-test b/script/docker-ui-test new file mode 100755 index 0000000..0e6ebe5 --- /dev/null +++ b/script/docker-ui-test @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Run the UI's (ClojureScript) tests inside a Node + JDK image; needs only Docker. +set -euo pipefail +cd "$(dirname "$0")/.." +source script/lib/docker.sh + +build_ui_test_image +exec docker run --rm --security-opt apparmor=unconfined tbd-ui-test diff --git a/script/lib/docker.sh b/script/lib/docker.sh index c1a2345..ae86da1 100644 --- a/script/lib/docker.sh +++ b/script/lib/docker.sh @@ -18,3 +18,9 @@ build_unit_image() { use_builder docker buildx build --load --build-arg DEPS_ALIASES=test:cov -t tbd-unit . } + +# Builds the UI's test image (Node + JDK, the UI's deps, sources and tests), tagged tbd-ui-test. +build_ui_test_image() { + use_builder + docker buildx build --load --target ui-test -t tbd-ui-test . +} diff --git a/script/test b/script/test index 8456e79..33884e4 100755 --- a/script/test +++ b/script/test @@ -1,7 +1,8 @@ #!/usr/bin/env bash -# Run the unit tests, then the integration tests (stops at the first failure). +# Run the UI tests, the unit tests, then the integration tests (stops at the first failure). set -euo pipefail cd "$(dirname "$0")/.." +script/ui-test script/unit-test script/integration-test diff --git a/script/ui-coverage b/script/ui-coverage new file mode 100755 index 0000000..6e8a52b --- /dev/null +++ b/script/ui-coverage @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Run the UI's tests with line coverage (c8, mapped back to the ClojureScript); the report goes +# to target/coverage/ui. Needs Node and a JDK, like script/ui-test. +set -euo pipefail +cd "$(dirname "$0")/../ui" + +[[ -d node_modules ]] || npm ci +exec npm run coverage diff --git a/script/ui-test b/script/ui-test index f68fe37..f046a7e 100755 --- a/script/ui-test +++ b/script/ui-test @@ -1,7 +1,8 @@ #!/usr/bin/env bash -# Run the UI's (ClojureScript) tests under Node. Needs Node and a JDK, like script/ui-build. +# Run the UI's (ClojureScript) tests under Node, in a jsdom window. Needs Node and a JDK, like +# script/ui-build. script/docker-ui-test does the same with only Docker. set -euo pipefail cd "$(dirname "$0")/../ui" [[ -d node_modules ]] || npm ci -exec npx shadow-cljs compile test +exec npm test diff --git a/ui/.c8rc.json b/ui/.c8rc.json new file mode 100644 index 0000000..478f0d5 --- /dev/null +++ b/ui/.c8rc.json @@ -0,0 +1,7 @@ +{ + "temp-directory": "target/c8", + "reports-dir": "../target/coverage/ui", + "reporter": ["text", "html", "lcovonly"], + "include": [".shadow-cljs/builds/test/dev/out/cljs-runtime/tbd.ui.*.js"], + "exclude": ["**/node_modules/**", "**/*_test.js", "**/tbd.ui.test_util.js"] +} diff --git a/ui/package-lock.json b/ui/package-lock.json index d8e30a1..950b42e 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -13,9 +13,225 @@ "react-dom": "^19.3.0" }, "devDependencies": { + "c8": "^12.0.0", + "jsdom": "^30.1.0", "shadow-cljs": "^3.5.3" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.0.0.tgz", + "integrity": "sha512-IxddtGnnoidVSO6O/aiTueptbTThHvi2qGHu9/AWuD1J7i86Ox+jl+Y5SXcDH1RixzQlP2cjH/PjnlImZiAurQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.2.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.0.tgz", + "integrity": "sha512-qKvLetaSd/OTWif9JT0eu1SHBg3H7Nvp9SoMwMxqT5tmYyrxea/P1A5pIfGbnIJlOtm3yVeqw+ORxDOkjN1aEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.0.tgz", + "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.3.tgz", + "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.4.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.1.tgz", + "integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@hono/node-server": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", @@ -28,6 +244,16 @@ "hono": "^4" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jason.today/webmcp": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/@jason.today/webmcp/-/webmcp-0.1.13.tgz", @@ -49,6 +275,34 @@ "webmcp": "build/index.js" } }, + "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.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", @@ -89,6 +343,13 @@ } } }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -135,6 +396,42 @@ } } }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -156,6 +453,16 @@ ], "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -193,6 +500,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -234,6 +554,40 @@ "node": ">= 0.8" } }, + "node_modules/c8": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz", + "integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^18.0.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -269,6 +623,39 @@ "integrity": "sha512-Wmza/JzL0SiWz7kl6MhIKT5ceIlnFPJX+lwUGj7Clhy5MMldsSoJR0+uvRzOS5Kv45Mq7t1PoE8TsOA9bzvb6g==", "license": "ISC" }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -291,6 +678,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -368,6 +762,49 @@ "deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.", "license": "ISC" }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -385,6 +822,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -426,6 +870,13 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -435,6 +886,19 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -477,6 +941,16 @@ "node": ">= 0.4" } }, + "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-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -618,6 +1092,40 @@ "url": "https://opencollective.com/express" } }, + "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/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "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/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -645,6 +1153,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/get-east-asian-width": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -682,6 +1213,24 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -694,6 +1243,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -727,6 +1286,26 @@ "node": ">=16.9.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/http": { "version": "0.0.1-security", "resolved": "https://registry.npmjs.org/http/-/http-0.0.1-security.tgz", @@ -813,6 +1392,13 @@ "node": ">= 0.10" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -829,6 +1415,45 @@ "node": ">=18" } }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jose": { "version": "6.2.12", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", @@ -838,6 +1463,46 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/jsdom": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.0.tgz", + "integrity": "sha512-h/Q+H+KmlGf49ITw3TrykSVbg04S75S2O7ZtJc9gMGOWBrQRZmx5AJIKLtJ01e6Kszl+/tpFthCVqxNifxhoqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.1.2", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.10.2", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -850,6 +1515,48 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "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/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -859,6 +1566,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", @@ -909,6 +1623,32 @@ "url": "https://opencollective.com/express" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "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==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -992,6 +1732,51 @@ "integrity": "sha512-ZoXJkvAnljwvc56MbvhtKVWmSkzV712k42Is2mA0+0KTSRakq5XXuXpjZjgAt9ctzl51ojhQWakQQpmOvXWfjQ==", "license": "MIT" }, + "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/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1011,6 +1796,16 @@ "util": "^0.10.3" } }, + "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", @@ -1020,6 +1815,23 @@ "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==", + "dev": true, + "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/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -1177,12 +1989,38 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -1356,6 +2194,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -1366,6 +2217,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -1386,6 +2247,87 @@ "node": ">= 0.8" } }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "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/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/tldts": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.13.tgz", + "integrity": "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.13" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.13", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.13.tgz", + "integrity": "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==", + "dev": true, + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1395,6 +2337,42 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tr46/node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -1426,6 +2404,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -1463,6 +2451,21 @@ "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", "license": "ISC" }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -1472,6 +2475,54 @@ "node": ">= 0.8" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz", + "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", @@ -1488,6 +2539,42 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -1515,6 +2602,84 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "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": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "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" + } + }, "node_modules/zod": { "version": "4.6.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", diff --git a/ui/package.json b/ui/package.json index 863a937..d9612c7 100644 --- a/ui/package.json +++ b/ui/package.json @@ -5,7 +5,9 @@ "description": "ClojureScript (re-frame) front end for the tbd server", "scripts": { "watch": "shadow-cljs watch app", - "release": "shadow-cljs release app" + "release": "shadow-cljs release app", + "test": "shadow-cljs compile test && node -r ./test/dom-setup.cjs target/ui-test/test.js", + "coverage": "shadow-cljs compile test && c8 --reporter=none node -r ./test/dom-setup.cjs target/ui-test/test.js && cp -r src/tbd .shadow-cljs/builds/test/dev/out/cljs-runtime/ && c8 report" }, "dependencies": { "@jason.today/webmcp": "0.1.13", @@ -13,6 +15,8 @@ "react-dom": "^19.3.0" }, "devDependencies": { + "c8": "^12.0.0", + "jsdom": "^30.1.0", "shadow-cljs": "^3.5.3" } } diff --git a/ui/shadow-cljs.edn b/ui/shadow-cljs.edn index 43fb897..cd7955c 100644 --- a/ui/shadow-cljs.edn +++ b/ui/shadow-cljs.edn @@ -13,8 +13,9 @@ :modules {:main {:init-fn tbd.ui.core/init}} :devtools {:after-load tbd.ui.core/reload}} - ;; script/ui-test: the tests run under Node, against fakes of fetch and navigator.modelContext. + ;; script/ui-test (npm test): the tests run under Node, in a jsdom window (test/dom-setup.cjs, + ;; which npm test preloads), against a fake server (fetch). Not :autorun: the preload has to + ;; come first, so npm test starts node itself. :test {:target :node-test :output-to "target/ui-test/test.js" - :ns-regexp "-test$" - :autorun true}}} + :ns-regexp "-test$"}}} diff --git a/ui/test/dom-setup.cjs b/ui/test/dom-setup.cjs new file mode 100644 index 0000000..a064589 --- /dev/null +++ b/ui/test/dom-setup.cjs @@ -0,0 +1,66 @@ +// Preloaded by `npm test` (node -r) before any compiled code runs: gives the ClojureScript +// tests a browser-like global scope (window, document, location, ...) backed by jsdom. +// +// It has to happen first: React DOM decides at load time whether the browser supports the +// `input` event; without a `window` then, never fires under test. +const { JSDOM } = require("jsdom"); + +const { window } = new JSDOM('
', { + url: "http://localhost:3000/", +}); + +// Copy the window's globals over, but leave Node's own (fetch, setTimeout, navigator, ...) alone. +for (const key of Object.getOwnPropertyNames(window)) { + if (!(key in globalThis)) { + Object.defineProperty(globalThis, key, { + configurable: true, + get: () => window[key], + set: (value) => Object.defineProperty(globalThis, key, { value, writable: true, configurable: true }), + }); + } +} +globalThis.window = window; + +// WebMCP, for `require("@jason.today/webmcp/src/webmcp.js")`. Its only export is +// `module.exports = WebMCP`, guarded by `typeof module`, in a file its package.json declares as an +// ES module. The browser build wraps it as CommonJS, so there it exports the class; Node's +// require() loads it as ESM, where `module` doesn't exist, and gets nothing. So run the same +// source with a `module` to export to, and hand that to require. +const Module = require("module"); +const fs = require("fs"); + +const webmcp = require.resolve("@jason.today/webmcp/src/webmcp.js"); +const cjs = new Module(webmcp); +cjs.filename = webmcp; +new Function("module", "exports", fs.readFileSync(webmcp, "utf8"))(cjs, cjs.exports); +cjs.loaded = true; +require.cache[webmcp] = cjs; + +// A run that doesn't finish must not look like a pass. An async test that throws before it calls +// `done` (or whose promise chain rejects) never ends: the process then hangs, or runs out of things +// to do and exits 0 without a word. Fail instead, and say which test it was. +function fail(message) { + console.error(`\nFAILED: ${message}`); + process.exit(1); +} + +process.on("unhandledRejection", (reason) => + fail(`unhandled promise rejection (an async test without a catch?): ${(reason && reason.stack) || reason}`)); + +// tbd.ui.test-util keeps `uiTestsCurrent` up to date: the test that is running, and since when. +const STUCK_MS = 30000; +globalThis.uiTestsCurrent = { name: "loading the tests", since: Date.now() }; +setInterval(() => { + const { name, since } = globalThis.uiTestsCurrent; + if (Date.now() - since > STUCK_MS) { + fail(`stuck for ${STUCK_MS / 1000}s in ${name} (an async test that never called done?)`); + } +}, 1000).unref(); + +// ... and sets `uiTestsFinished` once cljs.test has printed its summary. +process.on("exit", (code) => { + if (code === 0 && !globalThis.uiTestsFinished) { + console.error("\nFAILED: the tests ended without reporting a result"); + process.exitCode = 1; + } +}); diff --git a/ui/test/tbd/ui/core_test.cljs b/ui/test/tbd/ui/core_test.cljs new file mode 100644 index 0000000..329137e --- /dev/null +++ b/ui/test/tbd/ui/core_test.cljs @@ -0,0 +1,68 @@ +(ns tbd.ui.core-test + (:require [cljs.test :refer [deftest is async use-fixtures]] + [re-frame.db :as rf-db] + [reagent.dom.client :as rdc] + [tbd.ui.core :as core] + [tbd.ui.test-util :as tu] + [tbd.ui.webmcp :as webmcp])) + +;; init runs as in the browser, WebMCP widget included (the library is loaded through +;; test/webmcp-shim.cjs; see shadow-cljs.edn). Each test gets a fresh widget, and it is taken +;; off the page afterwards. + +(defn- widget [] @@#'webmcp/widget) + +(defn- tools-in-widget + "The names of the tools the WebMCP widget offers its client." + [] + (set (js/Array.from (.keys (.-availableTools ^js (widget)))))) + +(def ^:private real-log (.-log js/console)) + +(use-fixtures :each + tu/fake-server-fixture + {:before (fn [] + (reset! @#'webmcp/widget nil) + ;; The widget logs every tool it registers. (Not console.log as a whole: that is + ;; where the test report goes.) + (set! (.-log js/console) + (fn [& args] + (when-not (re-find #"^(Tool registered|Saved items)" (str (first args))) + (.apply real-log js/console (to-array args)))))) + :after (fn [] + (set! (.-log js/console) real-log) + (when-let [w (widget)] + (some-> (.getElementById js/document (.-elementId ^js w)) .remove)) + (reset! @#'webmcp/widget nil) + (.clear js/sessionStorage) + ;; init mounts the page in a root of its own. + (when-let [root @core/root] + (rdc/unmount root) + (reset! core/root nil)))}) + +(deftest init-test + (tu/log-in!) + (async done + (tu/finish + (tu/steps + (fn [] + (core/init) + (is (= :unknown (get-in @rf-db/app-db [:user :status])) "it starts out not knowing who is logged in") + (tu/shows "the page is on the screen" "main h1" "tbd")) + (fn [] + (is (some? (.getElementById js/document (.-elementId ^js (widget)))) "the WebMCP widget is on the page") + (is (= (set (map :name webmcp/tools)) (tools-in-widget)) "it offers the page's tools to the agent") + ;; A tool, as the agent's client calls it. + (let [ping (.-execute (.get (.-availableTools ^js (widget)) "ping"))] + (-> (ping #js {}) + (.then (fn [result] (is (= "pong" (.. result -content (at 0) -text)))))))) + (fn [] + (tu/shows "it found out who is logged in, and shows the todos" "section h2" "Todos")) + (fn [] + (is (= :logged-in (get-in @rf-db/app-db [:user :status]))) + ;; The hot-reload hook redraws and registers the tools again, without starting over. + (.clear (.-availableTools ^js (widget))) + (core/reload) + (is (= (set (map :name webmcp/tools)) (tools-in-widget))) + (tu/shows "still there after a reload" "main h1" "tbd"))) + done))) diff --git a/ui/test/tbd/ui/events_test.cljs b/ui/test/tbd/ui/events_test.cljs new file mode 100644 index 0000000..0c0082a --- /dev/null +++ b/ui/test/tbd/ui/events_test.cljs @@ -0,0 +1,323 @@ +(ns tbd.ui.events-test + (:require [cljs.test :refer [deftest is testing async use-fixtures]] + [re-frame.core :as rf] + [re-frame.db :as rf-db] + [tbd.ui.db :as db] + [tbd.ui.events :as events] + [tbd.ui.test-util :as tu])) + +;; The handlers run for real; what they ask the outside world for (a request, the clipboard, a +;; timer) is recorded in `effects` instead of happening, and the test plays the answer. Events +;; they set off with :dispatch wait in `queued` until `dispatch!` runs them (re-frame itself +;; would run them a moment later, and refuses dispatch-sync from inside a handler). + +(def ^:private effects (atom [])) +(def ^:private queued (atom [])) + +(defn- record [id] + (fn [value] (swap! effects conj [id value]))) + +(use-fixtures :each + {:before (fn [] (reset! effects []) (reset! queued []))} + (tu/stub-effects-fixture {::events/fetch (record ::events/fetch) + ::events/copy-to-clipboard (record ::events/copy-to-clipboard) + :dispatch-later (record :dispatch-later) + :dispatch #(swap! queued conj %)})) + +(defn- dispatch! + "Dispatches `event` and, one after the other, the events it sets off." + [event] + (rf/dispatch-sync event) + (while (seq @queued) + (let [[next-event] @queued] + (swap! queued subvec 1) + (rf/dispatch-sync next-event)))) + +(defn- app-db [] @rf-db/app-db) + +(defn- requests + "The requests asked for so far, as {:uri :method :body :on-response}." + [] + (mapv second (filter (comp #{::events/fetch} first) @effects))) + +(defn- last-request [] (last (requests))) + +(defn- answer! + "The server answers the last request." + ([status body] (answer! (last-request) status body)) + ([request status body] + (dispatch! (conj (:on-response request) + {:ok? (< status 300) :status status :body (if (string? body) body (pr-str body))})))) + +(defn- start! + "The page is starting: the db is initialized, and it is asking who is logged in (the only + request so far)." + [] + (reset! effects []) + (dispatch! [::events/initialize])) + +(def ^:private todo-1 {:id 1 :owner-id 7 :title "buy milk" :done false :created-at 1 :updated-at 1}) +(def ^:private todo-2 {:id 2 :owner-id 7 :title "walk dog" :done false :created-at 2 :updated-at 2}) + +(defn- logged-in! + "The page is up, alice is logged in and her todos (`items`) are shown." + [items] + (start!) + (answer! 200 {:login "alice" :id 7}) + (answer! 200 {:todos items}) + (reset! effects [])) + +;; --- Starting up --- + +(deftest initialize-test + (dispatch! [::events/initialize]) + (is (= db/default-db (app-db))) + (testing "it asks who is logged in" + (is (= [{:uri "/auth/me" :on-response [::events/user-response]}] (requests))))) + +;; --- Calculator --- + +(deftest set-operand-test + (start!) + (dispatch! [::events/set-operand :x "1.5"]) + (dispatch! [::events/set-operand :y "-2"]) + (is (= {:x "1.5" :y "-2"} (select-keys (:calc (app-db)) [:x :y])))) + +(deftest add-test + (start!) + (dispatch! [::events/set-operand :x "1.5"]) + (dispatch! [::events/set-operand :y "-2"]) + (dispatch! [::events/add]) + (testing "it asks the server, and shows that it is waiting" + (is (= "/api/add?x=1.5&y=-2" (:uri (last-request)))) + (is (nil? (:method (last-request))) "a GET") + (is (= {:status :loading} (get-in (app-db) [:calc :result])))) + (testing "the sum comes back" + (answer! 200 {:result -0.5}) + (is (= {:status :ok :x "1.5" :y "-2" :sum -0.5} (get-in (app-db) [:calc :result]))))) + +(deftest add-encodes-the-operands-test + (start!) + (dispatch! [::events/set-operand :x "1&y=9"]) + (dispatch! [::events/set-operand :y "a b"]) + (dispatch! [::events/add]) + (is (= "/api/add?x=1%26y%3D9&y=a%20b" (:uri (last-request))))) + +(deftest add-error-test + (start!) + (testing "the server's message and the fields it objects to" + (dispatch! [::events/add]) + (answer! 400 {:error "Invalid parameters" :details {:x ["must be a number"] :y ["must be a number"]}}) + (is (= {:status :error :message "Invalid parameters (x, y)"} (get-in (app-db) [:calc :result])))) + (testing "a message without details" + (dispatch! [::events/add]) + (answer! 500 {:error "Boom"}) + (is (= "Boom" (get-in (app-db) [:calc :result :message])))) + (testing "an answer that isn't EDN falls back to the status" + (dispatch! [::events/add]) + (answer! 502 "Bad gateway") + (is (= "HTTP 502" (get-in (app-db) [:calc :result :message])))) + (testing "so does one that can't be read at all" + (dispatch! [::events/add]) + (answer! 500 "{:error") + (is (= "HTTP 500" (get-in (app-db) [:calc :result :message])))) + (testing "an empty answer too" + (dispatch! [::events/add]) + (answer! 500 "") + (is (= "HTTP 500" (get-in (app-db) [:calc :result :message])))) + (testing "no answer at all shows what went wrong" + (dispatch! [::events/add]) + (dispatch! (conj (:on-response (last-request)) {:ok? false :status nil :body "Failed to fetch"})) + (is (= "Failed to fetch" (get-in (app-db) [:calc :result :message]))))) + +;; --- Login --- + +(deftest user-response-test + (testing "logged in: remembers who, and loads the todos" + (start!) + (answer! 200 {:login "alice" :id 7}) + (is (= {:status :logged-in :login "alice"} (:user (app-db)))) + (is (= "/api/todos" (:uri (last-request))))) + (testing "401 only means nobody is logged in" + (start!) + (answer! 401 {:error "not logged in"}) + (is (= {:status :anonymous} (:user (app-db)))) + (is (= 1 (count (requests))) "so no todos are asked for")) + (testing "anything else is a failure" + (start!) + (answer! 500 {:error "Boom"}) + (is (= {:status :error :message "Boom"} (:user (app-db)))))) + +(deftest logout-test + (logged-in! [todo-1]) + (dispatch! [::events/logout]) + (is (= {:uri "/auth/logout" :method "POST" :on-response [::events/logout-response]} (last-request))) + (testing "the page forgets the user and their todos" + (answer! 200 {:logged-out true}) + (is (= {:status :anonymous} (:user (app-db)))) + (is (= (:todos db/default-db) (:todos (app-db)))))) + +(deftest logout-failure-test + (logged-in! [todo-1]) + (dispatch! [::events/logout]) + (answer! 500 {:error "Boom"}) + (is (= :logged-in (get-in (app-db) [:user :status])) "still logged in") + (is (= [todo-1] (get-in (app-db) [:todos :items]))) + (is (= "Boom" (get-in (app-db) [:todos :error])))) + +;; --- Todos --- + +(deftest load-todos-test + (start!) + (answer! 200 {:login "alice" :id 7}) + (is (= :loading (get-in (app-db) [:todos :status]))) + (testing "the list arrives" + (answer! 200 {:todos [todo-1 todo-2]}) + (is (= :ok (get-in (app-db) [:todos :status]))) + (is (= [todo-1 todo-2] (get-in (app-db) [:todos :items])))) + (testing "a failed load shows the message, and the next one clears it" + (dispatch! [::events/load-todos]) + (answer! 500 {:error "Boom"}) + (is (= {:status :error :error "Boom"} (select-keys (:todos (app-db)) [:status :error]))) + (dispatch! [::events/load-todos]) + (is (nil? (get-in (app-db) [:todos :error]))))) + +(deftest add-todo-test + (logged-in! [todo-1]) + (dispatch! [::events/set-new-title "walk dog"]) + (is (= "walk dog" (get-in (app-db) [:todos :new-title]))) + (dispatch! [::events/add-todo]) + (is (= {:uri "/api/todos" :method "POST" :body {:title "walk dog"} :on-response [::events/todo-added]} + (last-request))) + (testing "the new todo joins the list and the field is emptied" + (answer! 201 todo-2) + (is (= [todo-1 todo-2] (get-in (app-db) [:todos :items]))) + (is (= "" (get-in (app-db) [:todos :new-title]))))) + +(deftest add-todo-failure-test + (logged-in! [todo-1]) + (dispatch! [::events/set-new-title " "]) + (dispatch! [::events/add-todo]) + (answer! 400 {:error "Invalid body" :details {:title ["must not be blank"]}}) + (testing "the message shows, and the list and what was typed stay as they are" + (is (= "Invalid body (title)" (get-in (app-db) [:todos :error]))) + (is (= [todo-1] (get-in (app-db) [:todos :items]))) + (is (= " " (get-in (app-db) [:todos :new-title])))) + (testing "the next request clears the message" + (dispatch! [::events/add-todo]) + (is (nil? (get-in (app-db) [:todos :error]))))) + +(deftest set-done-test + (logged-in! [todo-1 todo-2]) + (dispatch! [::events/set-done 2 true]) + (is (= {:uri "/api/todos/2" :method "PATCH" :body {:done true} :on-response [::events/todo-changed]} + (last-request))) + (testing "the todo is replaced by what the server made of it, in place" + (answer! 200 (assoc todo-2 :done true :updated-at 5)) + (is (= [todo-1 (assoc todo-2 :done true :updated-at 5)] (get-in (app-db) [:todos :items])))) + (testing "a failure keeps the list and shows why" + (dispatch! [::events/set-done 1 true]) + (answer! 404 {:error "todo not found"}) + (is (= "todo not found" (get-in (app-db) [:todos :error]))) + (is (false? (:done (first (get-in (app-db) [:todos :items]))))))) + +(deftest delete-todo-test + (logged-in! [todo-1 todo-2]) + (dispatch! [::events/delete-todo 1]) + (is (= {:uri "/api/todos/1" :method "DELETE" :on-response [::events/todo-deleted 1]} (last-request))) + (testing "it goes from the list" + (answer! 200 {:deleted true}) + (is (= [todo-2] (get-in (app-db) [:todos :items])))) + (testing "a failure keeps the list and shows why" + (dispatch! [::events/delete-todo 2]) + (answer! 500 {:error "Boom"}) + (is (= [todo-2] (get-in (app-db) [:todos :items]))) + (is (= "Boom" (get-in (app-db) [:todos :error]))))) + +(deftest expired-session-test + (testing "a 401 to any change falls back to the logged-out view" + (doseq [[event] [[[::events/add-todo]] + [[::events/set-done 1 true]] + [[::events/delete-todo 1]]]] + (logged-in! [todo-1]) + (dispatch! event) + (answer! 401 {:error "not logged in"}) + (is (= {:status :anonymous} (:user (app-db))) (str event)) + (is (= (:todos db/default-db) (:todos (app-db))) "the todos are hidden and forgotten")))) + +;; --- MCP setup --- + +(deftest copy-mcp-command-test + (start!) + (reset! effects []) + (dispatch! [::events/copy-mcp-command "claude mcp add ..."]) + (is (true? (get-in (app-db) [:mcp :copied?]))) + (testing "it asks for the command to be copied, and for the button to go back after a moment" + (is (= [[::events/copy-to-clipboard "claude mcp add ..."] + [:dispatch-later [{:ms 2000 :dispatch [::events/mcp-copied-timeout]}]]] + @effects))) + (dispatch! [::events/mcp-copied-timeout]) + (is (false? (get-in (app-db) [:mcp :copied?])))) + +;; --- The effects themselves --- + +(defn- with-clipboard + "Runs `f` with navigator.clipboard set to `clipboard` (or absent, when nil)." + [clipboard f] + (js/Object.defineProperty js/navigator "clipboard" #js {:value clipboard :configurable true}) + (try (f) (finally (js-delete js/navigator "clipboard")))) + +(deftest copy-to-clipboard-effect-test + (let [copy (tu/real-effect ::events/copy-to-clipboard) + written (atom [])] + (testing "it writes to the clipboard" + (with-clipboard #js {:writeText #(do (swap! written conj %) (js/Promise.resolve))} + #(copy "hello")) + (is (= ["hello"] @written))) + (testing "a clipboard that refuses is ignored" + (with-clipboard #js {:writeText (fn [_] (js/Promise.reject (js/Error. "denied")))} + #(copy "hello"))) + (testing "no clipboard, no error" + (with-clipboard nil #(copy "hello"))))) + +(deftest fetch-effect-test + ;; The real effect, against a fake `fetch`; the answer is dispatched to ::answered. + (let [fetch-fx (tu/real-effect ::events/fetch) + calls (atom []) + answer (atom nil) + real-fetch (.-fetch js/globalThis)] + (rf/reg-event-fx ::answered (fn [_ [_ resp]] (@answer resp) {})) + (let [ask (fn [request reply] + (js/Promise. (fn [resolve] + (reset! answer #(resolve %)) + (aset js/globalThis "fetch" reply) + (fetch-fx (assoc request :on-response [::answered])))))] + (async done + (-> (tu/steps + #(ask {:uri "/api/ping"} + (fn [uri opts] + (swap! calls conj [uri (js->clj opts)]) + (js/Promise.resolve #js {:ok true :status 200 :text (fn [] (js/Promise.resolve "pong"))}))) + (fn [resp] + (testing "a plain GET" + (is (= [["/api/ping" {"method" "GET"}]] @calls)) + (is (= {:ok? true :status 200 :body "pong"} resp))) + (reset! calls []) + (ask {:uri "/api/todos" :method "POST" :body {:title "x"}} + (fn [uri opts] + (swap! calls conj [uri (js->clj opts)]) + (js/Promise.resolve #js {:ok false :status 400 :text (fn [] (js/Promise.resolve "bad"))})))) + (fn [resp] + (testing "a body is sent as EDN" + (is (= [["/api/todos" {"method" "POST" + "headers" {"Content-Type" "application/edn"} + "body" "{:title \"x\"}"}]] + @calls))) + (testing "a failure status is passed on, with the body" + (is (= {:ok? false :status 400 :body "bad"} resp))) + (ask {:uri "/api/ping"} (fn [_ _] (js/Promise.reject (js/Error. "Failed to fetch"))))) + (fn [resp] + (testing "a network failure has no status, and says what went wrong" + (is (= {:ok? false :status nil :body "Failed to fetch"} resp))))) + (.finally #(aset js/globalThis "fetch" real-fetch)) + (tu/finish done)))))) diff --git a/ui/test/tbd/ui/subs_test.cljs b/ui/test/tbd/ui/subs_test.cljs new file mode 100644 index 0000000..1d5141e --- /dev/null +++ b/ui/test/tbd/ui/subs_test.cljs @@ -0,0 +1,55 @@ +(ns tbd.ui.subs-test + (:require [cljs.test :refer [deftest is testing use-fixtures]] + [re-frame.core :as rf] + [re-frame.db :as rf-db] + [re-frame.subs :as rf-subs] + [tbd.ui.db :as db] + [tbd.ui.subs :as subs] + [tbd.ui.tictactoe :as ttt])) + +(use-fixtures :each + {:before #(do (rf/clear-subscription-cache!) + (reset! rf-db/app-db db/default-db))}) + +(defn- sub + "The value of a subscription. (Read outside of a component, which re-frame warns about.)" + [query] + (with-redefs [rf-subs/warn-when-not-reactive (fn [])] + @(rf/subscribe query))) + +(defn- db! [f & args] + (apply swap! rf-db/app-db f args)) + +(deftest plain-subs-test + (testing "a new page" + (is (= "" (sub [::subs/operand :x]))) + (is (= {:status :idle} (sub [::subs/result]))) + (is (= {:status :unknown} (sub [::subs/user]))) + (is (= (:todos db/default-db) (sub [::subs/todos]))) + (is (false? (sub [::subs/mcp-copied?])))) + (testing "they follow the db" + (db! assoc-in [:calc :x] "4") + (db! assoc-in [:calc :result] {:status :ok :x "4" :y "1" :sum 5}) + (db! assoc :user {:status :logged-in :login "alice"}) + (db! assoc-in [:todos :items] [{:id 1 :title "a"}]) + (db! assoc-in [:mcp :copied?] true) + (is (= "4" (sub [::subs/operand :x]))) + (is (= 5 (:sum (sub [::subs/result])))) + (is (= "alice" (:login (sub [::subs/user])))) + (is (= [{:id 1 :title "a"}] (:items (sub [::subs/todos])))) + (is (true? (sub [::subs/mcp-copied?]))))) + +(deftest ttt-game-test + (let [set-board! (fn [cells turn] (db! assoc :ttt {:board (mapv {\x :x \o :o} cells) :turn turn}))] + (testing "a new game: X to move, nobody has won" + (is (= {:board ttt/new-board :turn :x :outcome nil} (sub [::subs/ttt-game])))) + (testing "in progress" + (set-board! "x...o...." :x) + (is (= {:board [:x nil nil nil :o nil nil nil nil] :turn :x :outcome nil} + (sub [::subs/ttt-game])))) + (testing "a win, with the line" + (set-board! "xxxoo...." :o) + (is (= {:player :x :line [0 1 2]} (:outcome (sub [::subs/ttt-game]))))) + (testing "a draw" + (set-board! "xoxxoooxx" :o) + (is (= :draw (:outcome (sub [::subs/ttt-game]))))))) diff --git a/ui/test/tbd/ui/test_util.cljs b/ui/test/tbd/ui/test_util.cljs new file mode 100644 index 0000000..e2fc57a --- /dev/null +++ b/ui/test/tbd/ui/test_util.cljs @@ -0,0 +1,220 @@ +(ns tbd.ui.test-util + "Shared fakes for the UI tests: the server the page talks to (a stand-in for `fetch`), a way + to stub re-frame effects, and helpers to drive the page in jsdom (see dom-setup.cjs)." + (:require [cljs.reader :as reader] + [cljs.test :as t :refer [is]] + [re-frame.registrar :as registrar] + [reagent.dom.client :as rdc])) + +;; --- Telling dom-setup.cjs how the run is going (it fails a run that gets stuck or ends early) --- + +(let [begin (get-method t/report [::t/default :begin-test-var])] + (defmethod t/report [::t/default :begin-test-var] [m] + (when begin (begin m)) + (set! (.-uiTestsCurrent js/globalThis) + #js {:name (str (:name (meta (:var m)))) :since (js/Date.now)}))) + +(let [summary (get-method t/report [::t/default :summary])] + (defmethod t/report [::t/default :summary] [m] + (summary m) + (set! (.-uiTestsFinished js/globalThis) true))) + +;; --- A fake server --- + +(def server + "What the fake server holds: {:user nil-or-{:login ..}, :todos [..], :next-id n, :add-error msg}." + (atom nil)) + +(defn- respond [status body] + {:status status :body (pr-str body)}) + +(defn- route [method uri body] + (let [{:keys [user todos]} @server + todo-id (some->> (re-find #"^/api/todos/(\d+)$" uri) second js/parseInt)] + (cond + (= "/auth/me" uri) (if user (respond 200 user) (respond 401 {:error "not logged in"})) + (and (= "POST" method) (= "/auth/logout" uri)) (do (swap! server assoc :user nil) + (respond 200 {:logged-out true})) + (and (= "GET" method) (re-find #"^/api/add\?" uri)) + (let [[_ x y] (re-find #"x=([^&]*)&y=([^&]*)" uri)] + (if-let [error (:add-error @server)] + (respond 400 {:error error :details {:x ["is wrong"]}}) + (respond 200 {:result (+ (js/parseFloat x) (js/parseFloat y))}))) + (not user) (respond 401 {:error "not logged in"}) + (and (= "GET" method) (= "/api/todos" uri)) (respond 200 {:todos todos}) + (and (= "POST" method) (= "/api/todos" uri)) + (if (seq (.trim (:title body))) + (let [todo {:id (:next-id @server) :owner-id 7 :title (:title body) :done false + :created-at 1 :updated-at 1}] + (swap! server #(-> % (update :todos conj todo) (update :next-id inc))) + (respond 201 todo)) + (respond 400 {:error "Invalid body" :details {:title ["must not be blank"]}})) + (and (= "PATCH" method) todo-id) + (if-let [todo (first (filter #(= todo-id (:id %)) todos))] + (let [changed (merge todo body)] + (swap! server update :todos (fn [ts] (mapv #(if (= todo-id (:id %)) changed %) ts))) + (respond 200 changed)) + (respond 404 {:error "todo not found"})) + (and (= "DELETE" method) todo-id) + (do (swap! server update :todos (fn [ts] (filterv #(not= todo-id (:id %)) ts))) + (respond 200 {:deleted true})) + :else (respond 404 {:error "not found"})))) + +(defn fake-fetch [uri opts] + (let [opts (js->clj opts :keywordize-keys true) + {:keys [status body]} (route (:method opts) uri (some-> (:body opts) reader/read-string))] + (js/Promise.resolve + #js {:ok (< status 300) :status status :text (fn [] (js/Promise.resolve body))}))) + +(def ^:private real-fetch (.-fetch js/globalThis)) + +(def fake-server-fixture + "Each test starts with an empty server, nobody logged in, and `fetch` talking to it." + {:before (fn [] + (reset! server {:user nil :todos [] :next-id 1}) + (aset js/globalThis "fetch" fake-fetch)) + :after (fn [] + (aset js/globalThis "fetch" real-fetch))}) + +(defn log-in! [] (swap! server assoc :user {:login "alice" :id 7})) + +;; --- Stubbing re-frame effects --- + +(defn- set-effect! + "Installs `handler` for effect `id`. (Not reg-fx: that warns about every overwrite.)" + [id handler] + (swap! registrar/kind->id->handler assoc-in [:fx id] handler)) + +(def ^:private real-effects + "The effect handlers as the app registered them, before any test stubbed one." + (atom {})) + +(defn real-effect + "The app's own handler for effect `id`, even while a test has it stubbed." + [id] + (or (get @real-effects id) + (let [handler (registrar/get-handler :fx id)] + (swap! real-effects assoc id handler) + handler))) + +(defn stub-effects-fixture + "A fixture that replaces re-frame effects for the length of a test. `stubs` is a map of effect + id to a function of the effect's value, which is called instead of the real handler." + [stubs] + {:before (fn [] + (doseq [[id handler] stubs] + (real-effect id) + (set-effect! id handler))) + :after (fn [] + (doseq [id (keys stubs)] + (set-effect! id (real-effect id))))}) + +;; --- Driving the page --- + +(defn- poll + "A promise of {:ok? bool :value v}: `(f)` as soon as it is truthy, or its last value after about + a second. The page updates asynchronously (re-frame queues events, React renders in the + background), so tests wait for what they expect to show up rather than for a fixed time." + [f] + (js/Promise. + (fn [resolve] + (let [start (js/Date.now) + check (fn check [] + (let [v (f)] + (cond + v (resolve {:ok? true :value v}) + (> (- (js/Date.now) start) 1000) (resolve {:ok? false :value v}) + :else (js/setTimeout check 2))))] + (check))))) + +(defn eventually + "A promise of `(f)`, once it is truthy. If it isn't within about a second the test fails (with + the place that waited), so a wait that can't succeed doesn't pass unnoticed." + [f] + (let [waited-at (js/Error. "timed out waiting for the page")] + (-> (poll f) + (.then (fn [{:keys [ok? value]}] + (when-not ok? + (t/do-report {:type :fail :message "timed out waiting for the page" + :expected "the condition to become true" + :actual (str "still " (pr-str value) "\n" (.-stack waited-at))})) + value))))) + +(defn q + "The first element of the page matching CSS `selector`, or nil." + [selector] + (.querySelector js/document selector)) + +(defn q-all [selector] + (vec (.querySelectorAll js/document selector))) + +(defn text + "The text of the first element matching `selector`, or nil when there is none." + [selector] + (some-> (q selector) .-textContent)) + +(defn shows + "A promise that checks that the text of `selector` becomes `expected` (`what` says what it is)." + [what selector expected] + (-> (poll #(= expected (text selector))) + (.then (fn [_] (is (= expected (text selector)) what))))) + +(defn by-label + "The element whose aria-label is `label`." + [label] + (q (str "[aria-label=\"" label "\"]"))) + +(defn click! [el] + (.click ^js el)) + +(defn type! + "Types `value` into an input: sets it the way the browser does and fires `input`, which is + what React's onChange listens to." + [el value] + ;; window.Event, not the global one: under Node that is Node's own, which jsdom rejects. + (let [setter (.-set (js/Object.getOwnPropertyDescriptor js/HTMLInputElement.prototype "value"))] + (.call setter el value) + (.dispatchEvent el (js/window.Event. "input" #js {:bubbles true})))) + +(defn submit! + "Submits the form `el` (an event, not requestSubmit, so it doesn't matter what the browser + would validate)." + [el] + (.dispatchEvent el (js/window.Event. "submit" #js {:bubbles true :cancelable true}))) + +(defonce ^:private root (atom nil)) + +(defn mount! + "Renders `component` (hiccup) into a fresh #app, as core/init does, and returns a promise." + [component] + (let [container (.getElementById js/document "app")] + (reset! root (rdc/create-root container)) + (rdc/render @root component) + (js/Promise.resolve))) + +(defn unmount! + "Removes what `mount!` rendered." + [] + (when-let [r @root] + (rdc/unmount r) + (reset! root nil))) + +(defn steps + "Runs `fs` one after the other, as a promise: each is called with the result of the previous + one, which may be a promise, and the promise resolves to the result of the last." + [& fs] + (reduce (fn [p f] (.then p f)) (js/Promise.resolve) fs)) + +(defn finish + "Ends an `async` test when the promise `p` settles; a rejection fails the test." + [p done] + (-> p + (.then (fn [_] (done))) + (.catch (fn [e] + (t/do-report {:type :error :message "Uncaught exception, not in assertion." + :expected nil :actual e}) + (done))))) + +(def page-fixture + "Leaves the page empty after each test." + {:after unmount!}) diff --git a/ui/test/tbd/ui/views_test.cljs b/ui/test/tbd/ui/views_test.cljs new file mode 100644 index 0000000..10ac685 --- /dev/null +++ b/ui/test/tbd/ui/views_test.cljs @@ -0,0 +1,381 @@ +(ns tbd.ui.views-test + "The page as a user sees it: rendered in jsdom, against a fake server, then typed into and + clicked. Everything from the DOM event to the request and back to the DOM is real." + (:require [clojure.string :as str] + [cljs.test :refer [deftest is testing async use-fixtures]] + [re-frame.core :as rf] + [re-frame.db :as rf-db] + [tbd.ui.db :as db] + [tbd.ui.events :as events] + [tbd.ui.test-util :as tu :refer [q q-all text shows steps click! type! submit!]] + [tbd.ui.views :as views] + [tbd.ui.webmcp :as webmcp])) + +(use-fixtures :each + tu/fake-server-fixture + tu/page-fixture + ;; The Copy button's 2 seconds are up when the test says so (see mcp-copy-button-test). + (tu/stub-effects-fixture {:dispatch-later (fn [_])})) + +(defn- open-page! + "Starts the app as core/init does, minus the WebMCP widget." + [] + (rf/dispatch-sync [::events/initialize]) + (tu/mount! [views/app])) + +(defn- todos-section + "The todos, which are only on the page when someone is logged in." + [] + (q "input[name=title]")) + +(defn- todo-titles [] + (mapv #(str/trim (.-textContent %)) (q-all "ul li label"))) + +(defn- nav-links [] + (mapv #(.-textContent %) (q-all "main > p:last-child a, main > p:last-child button"))) + +(defn- log-in-page! + "Opens the page with alice logged in, with `todos`, and waits for the list." + [todos] + (tu/log-in!) + (swap! tu/server assoc :todos todos :next-id (inc (count todos))) + (steps open-page! + #(tu/eventually todos-section) + #(tu/eventually (fn [] (or (q "ul li") (= "Nothing to do." (text "section > p"))))))) + +(def ^:private todo-1 {:id 1 :owner-id 7 :title "buy milk" :done false :created-at 1 :updated-at 1}) +(def ^:private todo-2 {:id 2 :owner-id 7 :title "walk dog" :done true :created-at 2 :updated-at 2}) + +;; --- The page as a visitor sees it --- + +(deftest anonymous-page-test + (async done + (tu/finish + (steps + open-page! + #(tu/eventually (fn [] (some #{"Log in"} (nav-links)))) + (fn [] + (is (= "tbd" (text "h1"))) + (is (= ["API docs" "swagger.json" "Log in"] (nav-links))) + (is (= "/auth/login" (.getAttribute (q "a[href=\"/auth/login\"]") "href"))) + (is (nil? (todos-section)) "no todos without a login") + (is (= ["Tic-tac-toe" "Add this MCP server to Claude Code" "Browser tools (WebMCP)"] + (mapv #(.-textContent %) (q-all "h2")))))) + done))) + +(deftest login-state-error-test + ;; When the page can't find out who is logged in, it offers neither Log in nor Log out. + (async done + (aset js/globalThis "fetch" + (fn [_ _] (js/Promise.resolve #js {:ok false :status 500 + :text (fn [] (js/Promise.resolve (pr-str {:error "Boom"})))}))) + (tu/finish + (steps + open-page! + #(tu/eventually (fn [] (= :error (get-in @rf-db/app-db [:user :status])))) + (fn [] + (is (= ["API docs" "swagger.json"] (nav-links))))) + done))) + +;; --- Calculator --- + +(deftest calculator-test + (async done + (tu/finish + (steps + open-page! + #(tu/eventually (fn [] (q "input[name=x]"))) + (fn [] + (is (nil? (q "form [role=alert]"))) + (type! (q "input[name=x]") "2") + (type! (q "input[name=y]") "3.5") + (tu/eventually (fn [] (= "3.5" (.-value (q "input[name=y]")))))) + (fn [] + (is (= "2" (.-value (q "input[name=x]"))) "the field shows what was typed") + (submit! (q "form")) + (shows "the sum" "form [aria-live=polite]" "2 + 3.5 = 5.5")) + (fn [] + (is (= "5.5" (text "form strong"))) + (swap! tu/server assoc :add-error "Invalid parameters") + (submit! (q "form")) + (shows "the server's complaint" "form [role=alert]" "Invalid parameters (x)")) + (fn [] + (is (nil? (q "form [aria-live=polite]")) "the old sum is gone"))) + done))) + +;; --- Todos --- + +(deftest todos-empty-test + (async done + (tu/finish + (steps + #(log-in-page! []) + (fn [] + (is (= "Nothing to do." (text "section > p"))) + (is (= "Log out" (last (nav-links)))) + (is (nil? (q "a[href=\"/auth/login\"]"))) + (is (= "Signed in as alice" (.getAttribute (q "main > p:last-child button") "title"))))) + done))) + +(deftest todos-list-test + (async done + (tu/finish + (steps + #(log-in-page! [todo-1 todo-2]) + (fn [] + (is (= ["buy milk" "walk dog"] (todo-titles))) + (is (= [false true] (mapv #(.-checked %) (q-all "ul li input[type=checkbox]")))) + (is (= [nil "line-through"] + (mapv #(not-empty (.. % -style -textDecoration)) (q-all "ul li label")))))) + done))) + +(deftest add-todo-test + (async done + (tu/finish + (steps + #(log-in-page! [todo-1]) + (fn [] + (type! (q "input[name=title]") "walk dog") + (submit! (q "section form"))) + #(tu/eventually (fn [] (= 2 (count (todo-titles))))) + (fn [] + (is (= ["buy milk" "walk dog"] (todo-titles))) + (is (= "" (.-value (q "input[name=title]"))) "the field is emptied") + (is (= ["buy milk" "walk dog"] (mapv :title (:todos @tu/server))) "the server has it"))) + done))) + +(deftest add-todo-error-test + (async done + (tu/finish + (steps + #(log-in-page! [todo-1]) + (fn [] + (type! (q "input[name=title]") " ") + (submit! (q "section form")) + (shows "the server's complaint" "section [role=alert]" "Invalid body (title)")) + (fn [] + (is (= ["buy milk"] (todo-titles))) + (is (= " " (.-value (q "input[name=title]"))) "what was typed stays")) + (fn [] + (type! (q "input[name=title]") "walk dog") + (submit! (q "section form")) + (tu/eventually (fn [] (nil? (q "section [role=alert]"))))) + (fn [gone] + (is gone "the message goes when the next attempt works"))) + done))) + +(deftest tick-and-untick-test + (async done + (tu/finish + (steps + #(log-in-page! [todo-1]) + (fn [] + (click! (q "ul li input[type=checkbox]")) + (tu/eventually (fn [] (.-checked (q "ul li input[type=checkbox]"))))) + (fn [] + (is (true? (:done (first (:todos @tu/server)))) "the server has it") + (tu/eventually (fn [] (= "line-through" (.. (q "ul li label") -style -textDecoration))))) + (fn [struck] + (is struck "the title is struck through") + (click! (q "ul li input[type=checkbox]")) + (tu/eventually (fn [] (not (.-checked (q "ul li input[type=checkbox]")))))) + (fn [unticked] + (is unticked) + (is (false? (:done (first (:todos @tu/server))))))) + done))) + +(deftest delete-todo-test + (async done + (tu/finish + (steps + #(log-in-page! [todo-1 todo-2]) + (fn [] + (click! (tu/by-label "Delete buy milk")) + (tu/eventually (fn [] (= ["walk dog"] (todo-titles))))) + (fn [_] + (is (= ["walk dog"] (todo-titles))) + (is (= [2] (mapv :id (:todos @tu/server)))) + (click! (tu/by-label "Delete walk dog")) + (tu/eventually (fn [] (= "Nothing to do." (text "section > p"))))) + (fn [emptied] + (is emptied "the empty list says so"))) + done))) + +(deftest todos-loading-test + ;; The list is on its way: logged in, nothing to show yet. + (async done + (reset! rf-db/app-db (-> db/default-db + (assoc :user {:status :logged-in :login "alice"}) + (assoc-in [:todos :status] :loading))) + (tu/finish + (steps + #(tu/mount! [views/app]) + #(shows "the list says it is loading" "section > p" "Loading…")) + done))) + +;; --- Logging out, and losing the session --- + +(deftest log-out-test + (async done + (tu/finish + (steps + #(log-in-page! [todo-1]) + (fn [] + (click! (q "main > p:last-child button"))) + #(tu/eventually (fn [] (some #{"Log in"} (nav-links)))) + (fn [] + (is (nil? (:user @tu/server))) + (is (nil? (todos-section)) "the todos are hidden") + (is (= ["API docs" "swagger.json" "Log in"] (nav-links))))) + done))) + +(deftest expired-session-test + (async done + (tu/finish + (steps + #(log-in-page! [todo-1]) + (fn [] + ;; The session ends behind the page's back; the next change is refused. + (swap! tu/server assoc :user nil) + (click! (q "ul li input[type=checkbox]"))) + #(tu/eventually (fn [] (some #{"Log in"} (nav-links)))) + (fn [] + (is (nil? (todos-section)) "the page falls back to the logged-out view"))) + done))) + +;; --- Tic-tac-toe --- + +(defn- cell + "The button of the cell at index `i` (0 is the top left): the labels count from 1." + [i] + (q (str "button[aria-label^=\"Cell " (inc i) "\"]"))) + +(defn- board [] + (mapv #(str/trim (.-textContent %)) (q-all "section button[aria-label^=Cell]"))) + +(defn- play! + "Clicks the cells, one after the other, and waits until the page shows a mark in each." + [& cells] + (steps + (fn [] + (doseq [n cells] (click! (cell n))) + (tu/eventually #(every? (fn [n] (seq (.-textContent (cell n)))) cells))))) + +(defn- status [] (text "[role=status]")) + +(deftest tic-tac-toe-test + (async done + (tu/finish + (steps + open-page! + #(tu/eventually (fn [] (q "[role=status]"))) + (fn [] + (is (= "X's turn" (status))) + (is (= (repeat 9 "") (board))) + (is (every? #(not (.-disabled %)) (q-all "section button[aria-label^=Cell]")))) + #(play! 4) + (fn [] + (is (= ["" "" "" "" "X" "" "" "" ""] (board))) + (is (= "O's turn" (status))) + (is (.-disabled (cell 4)) "a taken cell can't be pressed again") + (is (= "Cell 5, X" (.getAttribute (cell 4) "aria-label")))) + #(play! 0 2 3 6) + (fn [] + ;; X 4, O 0, X 2, O 3, X 6: the diagonal 2-4-6. + (is (= "X wins!" (status))) + (is (= ["O" "" "X" "O" "X" "" "X" "" ""] (board))) + (is (every? #(.-disabled %) (q-all "section button[aria-label^=Cell]")) "the game is over") + (is (= ["gold" "gold" "gold"] + (mapv #(.. % -style -background) + (filter #(= "gold" (.. % -style -background)) (q-all "section button[aria-label^=Cell]")))) + "the winning line is highlighted") + (click! (q "section p button"))) + #(tu/eventually (fn [] (= "X's turn" (status)))) + (fn [] + (is (= (repeat 9 "") (board)) "New game clears the board"))) + done))) + +(deftest tic-tac-toe-draw-test + (async done + (tu/finish + (steps + open-page! + #(tu/eventually (fn [] (q "[role=status]"))) + ;; X O X / X O O / O X X, played in turn: X 0, O 1, X 2, O 4, X 3, O 5, X 7, O 6, X 8. + #(play! 0 1 2 4 3 5 7 6 8) + (fn [] + (is (= "It's a draw." (status))) + (is (empty? (filter #(= "gold" (.. % -style -background)) (q-all "section button[aria-label^=Cell]")))))) + done))) + +;; --- The setup sections --- + +(defn- with-clipboard + "Runs `f` with navigator.clipboard set to `clipboard`, and takes it away afterwards." + [clipboard f] + (js/Object.defineProperty js/navigator "clipboard" #js {:value clipboard :configurable true}) + (-> (f) + (.finally #(js-delete js/navigator "clipboard")))) + +(deftest mcp-setup-test + (async done + (tu/finish + (steps + open-page! + #(tu/eventually (fn [] (q "details"))) + (fn [] + (let [details (first (q-all "details"))] + (is (nil? (.getAttribute details "open")) "folded away to start with") + (is (= "Add this MCP server to Claude Code" (text "details summary"))) + (is (= "claude mcp add --transport http tbd http://localhost:3000/mcp" (text "details pre code")) + "the command names the address the page was loaded from") + (is (= ["claude" "/mcp" "tbd"] + (mapv #(.-textContent %) (.querySelectorAll details "ol code"))))))) + done))) + +(deftest mcp-no-copy-button-without-clipboard-test + ;; The Clipboard API only exists on secure pages; without it there is nothing to press. + (async done + (tu/finish + (steps + open-page! + #(tu/eventually (fn [] (q "details"))) + (fn [] + (is (nil? (q "details pre + button"))))) + done))) + +(deftest mcp-copy-button-test + (async done + (let [copied (atom [])] + (tu/finish + (with-clipboard + #js {:writeText (fn [s] (swap! copied conj s) (js/Promise.resolve))} + (fn [] + (steps + open-page! + #(tu/eventually (fn [] (q "details pre + button"))) + (fn [] + (is (= "Copy" (text "details pre + button"))) + (click! (q "details pre + button"))) + #(shows "the button says so" "details pre + button" "Copied") + (fn [] + (is (= ["claude mcp add --transport http tbd http://localhost:3000/mcp"] @copied)) + ;; The 2 seconds are up. + (rf/dispatch-sync [::events/mcp-copied-timeout])) + #(shows "the button is back" "details pre + button" "Copy")))) + done)))) + +(deftest browser-tools-test + (async done + (tu/finish + (steps + open-page! + #(tu/eventually (fn [] (second (q-all "details")))) + (fn [] + (let [section (second (q-all "details"))] + (is (= "Browser tools (WebMCP)" (.-textContent (.querySelector section "summary")))) + (is (= (str (count webmcp/tools)) (.-textContent (.querySelector section "p strong"))) + "it counts the tools the page really offers") + (is (= "https://webmcp.dev" (.getAttribute (.querySelector section "p a") "href"))) + (is (= 4 (.-length (.querySelectorAll section "ol li"))))))) + done))) diff --git a/ui/test/tbd/ui/webmcp_test.cljs b/ui/test/tbd/ui/webmcp_test.cljs index 987e1e4..84797a4 100644 --- a/ui/test/tbd/ui/webmcp_test.cljs +++ b/ui/test/tbd/ui/webmcp_test.cljs @@ -1,55 +1,12 @@ (ns tbd.ui.webmcp-test - (:require [cljs.reader :as reader] - [cljs.test :refer [deftest is testing async use-fixtures]] + (:require [cljs.test :refer [deftest is testing async use-fixtures]] [re-frame.core :as rf] [re-frame.db :as rf-db] [tbd.ui.events :as events] + [tbd.ui.test-util :as tu] [tbd.ui.webmcp :as webmcp])) -;; A fake browser: the page's server, and the WebMCP registry the tools end up in. - -(def ^:private server - "What the fake server holds: {:user nil-or-{:login ..}, :todos [..], :next-id n}." - (atom nil)) - -(defn- respond [status body] - {:status status :body (pr-str body)}) - -(defn- route [method uri body] - (let [{:keys [user todos]} @server - todo-id (some->> (re-find #"^/api/todos/(\d+)$" uri) second js/parseInt)] - (cond - (= "/auth/me" uri) (if user (respond 200 user) (respond 401 {:error "not logged in"})) - (and (= "GET" method) (re-find #"^/api/add\?" uri)) - (let [[_ x y] (re-find #"x=([^&]*)&y=([^&]*)" uri)] - (if-let [error (:add-error @server)] - (respond 400 {:error error :details {:x ["is wrong"]}}) - (respond 200 {:result (+ (js/parseFloat x) (js/parseFloat y))}))) - (not user) (respond 401 {:error "not logged in"}) - (and (= "GET" method) (= "/api/todos" uri)) (respond 200 {:todos todos}) - (and (= "POST" method) (= "/api/todos" uri)) - (if (seq (.trim (:title body))) - (let [todo {:id (:next-id @server) :owner-id 7 :title (:title body) :done false - :created-at 1 :updated-at 1}] - (swap! server #(-> % (update :todos conj todo) (update :next-id inc))) - (respond 201 todo)) - (respond 400 {:error "Invalid body" :details {:title ["must not be blank"]}})) - (and (= "PATCH" method) todo-id) - (if-let [todo (first (filter #(= todo-id (:id %)) todos))] - (let [changed (merge todo body)] - (swap! server update :todos (fn [ts] (mapv #(if (= todo-id (:id %)) changed %) ts))) - (respond 200 changed)) - (respond 404 {:error "todo not found"})) - (and (= "DELETE" method) todo-id) - (do (swap! server update :todos (fn [ts] (filterv #(not= todo-id (:id %)) ts))) - (respond 200 {:deleted true})) - :else (respond 404 {:error "not found"})))) - -(defn- fake-fetch [uri opts] - (let [opts (js->clj opts :keywordize-keys true) - {:keys [status body]} (route (:method opts) uri (some-> (:body opts) reader/read-string))] - (js/Promise.resolve - #js {:ok (< status 300) :status status :text (fn [] (js/Promise.resolve body))}))) +;; A fake browser: the page's server (tbd.ui.test-util), and the WebMCP registry the tools end up in. (def ^:private registered "What the page registered with WebMCP: name -> {:description :input-schema :execute}." @@ -62,15 +19,11 @@ :input-schema input-schema :execute execute}))}) -(def ^:private real-fetch (.-fetch js/globalThis)) - (use-fixtures :each {:before (fn [] (reset! registered {}) - (reset! server {:user nil :todos [] :next-id 1}) - (aset js/globalThis "fetch" fake-fetch)) - :after (fn [] - (aset js/globalThis "fetch" real-fetch))}) + ((:before tu/fake-server-fixture))) + :after (:after tu/fake-server-fixture)}) (defn- call "A promise of the result of the tool `tool-name` on `args`, as {:text :error?}." @@ -95,8 +48,6 @@ (rf/dispatch-sync [::events/initialize]) (webmcp/register-tools! fake-mcp)) -(defn- log-in! [] (swap! server assoc :user {:login "alice" :id 7})) - (deftest registers-the-tools (is (identical? fake-mcp (webmcp/register-tools! fake-mcp))) (is (= (set (map :name webmcp/tools)) (set (keys @registered)))) @@ -139,7 +90,7 @@ (done)))))) (deftest add-shows-the-servers-error - (swap! server assoc :add-error "Invalid parameters") + (swap! tu/server assoc :add-error "Invalid parameters") (open-page!) (async done (-> (page-loaded) @@ -153,6 +104,33 @@ (is (re-find #"must be numbers" (:text r))) (done)))))) +(deftest login-state-unavailable + ;; The server can't say who is logged in: the tools that need to know say so. + (aset js/globalThis "fetch" + (fn [_ _] (js/Promise.resolve #js {:ok false :status 500 + :text (fn [] (js/Promise.resolve (pr-str {:error "Boom"})))}))) + (open-page!) + (async done + (-> (call "whoami" {}) + (.then (fn [r] + (is (= {:text "could not tell who is logged in: Boom" :error? false} r)) + (call "list_todos" {}))) + (.then (fn [r] + (is (= {:text "could not tell who is logged in: Boom" :error? true} r)) + (done)))))) + +(deftest add-while-adding + (open-page!) + (async done + (-> (page-loaded) + (.then (fn [_] + ;; A person pressed Add a moment ago and the sum hasn't come back. + (rf/dispatch-sync [::events/add]) + (call "add" {:x 1 :y 2}))) + (.then (fn [r] + (is (= {:text "an addition is already in progress" :error? true} r)) + (done)))))) + (deftest anonymous-user (open-page!) (async done @@ -166,12 +144,12 @@ (call "create_todo" {:title "buy milk"}))) (.then (fn [r] (is (:error? r)) - (is (empty? (:todos @server)) "nothing was sent") + (is (empty? (:todos @tu/server)) "nothing was sent") (done)))))) (deftest todos - (log-in!) - (swap! server assoc :todos [{:id 1 :owner-id 7 :title "old" :done false :created-at 1 :updated-at 1}] + (tu/log-in!) + (swap! tu/server assoc :todos [{:id 1 :owner-id 7 :title "old" :done false :created-at 1 :updated-at 1}] :next-id 2) (open-page!) (async done @@ -199,7 +177,7 @@ (is (true? (:done (json (:text r))))) (is (true? (:done (last (get-in @rf-db/app-db [:todos :items]))))) ;; Already ticked: nothing to do, and no request. - (swap! server assoc :todos []) + (swap! tu/server assoc :todos []) (call "set_todo_done" {:id 2 :done true}))) (.then (fn [r] (is (true? (:done (json (:text r))))) @@ -219,7 +197,7 @@ (done)))))) (deftest create-todo-shows-the-servers-error - (log-in!) + (tu/log-in!) (open-page!) (async done (-> (call "create_todo" {:title " "})