diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..28f1a1789 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +bazel-* +bin +web/**/.next +web/**/coverage +web/**/dist +web/**/node_modules +web/**/playwright-report +web/**/test-results +web/**/*.tsbuildinfo diff --git a/.github/actions/upload-testlogs/action.yml b/.github/actions/upload-testlogs/action.yml index 9abbe8192..af8a6f882 100644 --- a/.github/actions/upload-testlogs/action.yml +++ b/.github/actions/upload-testlogs/action.yml @@ -1,5 +1,5 @@ name: Upload Bazel test logs -description: Upload bazel-testlogs on job failure so truncated CI output is not the only diagnostic. +description: Upload bazel-testlogs and test outputs (for example Playwright traces and compose logs) on job failure so truncated CI output is not the only diagnostic. runs: using: composite @@ -8,6 +8,8 @@ runs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: testlogs-${{ github.job }}-${{ github.run_id }}-${{ github.run_attempt }} - path: bazel-testlogs/**/test.log + path: | + bazel-testlogs/**/test.log + bazel-testlogs/**/test.outputs/** if-no-files-found: warn retention-days: 7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7034316e2..6c752d9b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,6 +104,22 @@ jobs: if: ${{ failure() }} uses: ./.github/actions/upload-testlogs + web: + name: Web + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup + - name: Run web checks + run: make web-check + + - name: Upload Bazel failure logs + if: ${{ failure() }} + uses: ./.github/actions/upload-testlogs + # --------------------------------------------------------------------------- # INTEGRATION TESTS (e2e, gateway, orchestrator) # --------------------------------------------------------------------------- @@ -135,6 +151,22 @@ jobs: if: ${{ failure() }} uses: ./.github/actions/upload-testlogs + web-e2e: + name: Web E2E + if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: ./.github/actions/setup + - name: Run real-stack web E2E + run: make web-e2e-test + + - name: Upload Bazel failure logs + if: ${{ failure() }} + uses: ./.github/actions/upload-testlogs + gateway-integration-test: name: Gateway Integration Test if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} @@ -354,6 +386,8 @@ jobs: - merger-integration-test - storage-integration-test - consumer-integration-test + - web + - web-e2e - workflow-security steps: - name: Fail if any required check did not succeed diff --git a/.gitignore b/.gitignore index 3cc0d67cb..c9944f663 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,13 @@ bin/ # Documentation site build output /tool/docsite/build/ /tool/docsite/hooks/__pycache__/ + +# Web workspace build output +/web/node_modules/ +/web/**/node_modules/ +/web/**/dist/ +/web/**/.next/ +/web/**/coverage/ +/web/**/playwright-report/ +/web/**/test-results/ +/web/**/*.tsbuildinfo diff --git a/BUILD.bazel b/BUILD.bazel index dc5a819d1..e2e66dc3b 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -10,6 +10,7 @@ load("@gazelle//:def.bzl", "gazelle") # Exclude nested worktrees (created under .claude/worktrees) so gazelle does not # index them as duplicate rule definitions and corrupt the canonical BUILD files. # gazelle:exclude .claude +# gazelle:exclude web # Disable Gazelle proto rule generation globally. We rely on the checked-in # .pb.go files in protopb/ dirs (plain go_library) rather than Bazel proto diff --git a/MODULE.bazel b/MODULE.bazel index e63794f8f..a151efc55 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -5,7 +5,15 @@ bazel_dep(name = "git", version = "2.55.0") bazel_dep(name = "rules_go", version = "0.57.0") bazel_dep(name = "gazelle", version = "0.45.0") bazel_dep(name = "rules_proto", version = "7.1.0") +bazel_dep(name = "aspect_rules_js", version = "3.5.0") +bazel_dep(name = "aspect_rules_ts", version = "3.10.1") +bazel_dep(name = "rules_nodejs", version = "6.7.3") +bazel_dep(name = "rules_oci", version = "2.3.0") +bazel_dep(name = "rules_pkg", version = "1.0.1") +bazel_dep(name = "rules_playwright", version = "0.5.3") bazel_dep(name = "rules_python", version = "2.2.0") +bazel_dep(name = "bazel_lib", version = "3.7.2") +bazel_dep(name = "platforms", version = "1.1.0") single_version_override( module_name = "git", @@ -29,6 +37,52 @@ use_repo(protoc, "toolchains_protoc_hub") register_toolchains("@toolchains_protoc_hub//:all") +node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node") +node.toolchain(node_version = "22.20.0") + +typescript = use_extension("@aspect_rules_ts//ts:extensions.bzl", "typescript") +typescript.deps(version_from = "//web:package.json") +use_repo(typescript, "npm_typescript") + +npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm") +npm.npm_translate_lock( + name = "npm", + pnpm_lock = "//web:pnpm-lock.yaml", +) +use_repo(npm, "npm") + +playwright = use_extension("@rules_playwright//playwright:extensions.bzl", "playwright") +playwright.repo( + name = "playwright", + integrity_path_map = { + "builds/chromium/1193/chromium-linux.zip": "sha256-AzVW84unLl+wfBqcmm4P9WtYI8LMQF4DS6V2OmLirRI=", + "builds/chromium/1193/chromium-mac-arm64.zip": "sha256-v7p0571A21zCoO9gPsm6kXNs9ragg2wnpZabV7gES2E=", + }, + playwright_version = "1.55.1", +) +use_repo(playwright, "playwright") + +oci = use_extension("@rules_oci//oci:extensions.bzl", "oci") +oci.pull( + name = "node_22_20_0_bookworm_slim", + digest = "sha256:b21fe589dfbe5cc39365d0544b9be3f1f33f55f3c86c87a76ff65a02f8f5848e", + image = "node", + platforms = ["linux/amd64"], +) +oci.pull( + name = "mysql_8_0", + digest = "sha256:7dcddc01f13bab2f15cde676d44d01f61fc9f99fe7785e86196dfc07d358ae2b", + image = "mysql", + platforms = ["linux/amd64"], +) +use_repo( + oci, + "mysql_8_0", + "mysql_8_0_linux_amd64", + "node_22_20_0_bookworm_slim", + "node_22_20_0_bookworm_slim_linux_amd64", +) + GO_VERSION = "1.25.0" go_sdk = use_extension("@rules_go//go:extensions.bzl", "go_sdk") diff --git a/Makefile b/Makefile index 8dfd0f0b4..ac1961db9 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,7 @@ COMPOSE = docker-compose # SubmitQueue compose files COMPOSE_FILE = service/submitqueue/docker-compose.yml +WEB_COMPOSE_FILE = service/submitqueue/docker-compose.web.yml GATEWAY_COMPOSE_FILE = service/submitqueue/gateway/server/docker-compose.yml ORCHESTRATOR_COMPOSE_FILE = service/submitqueue/orchestrator/server/docker-compose.yml @@ -41,6 +42,11 @@ PROTO_PACKAGES = api/base/change api/base/hook api/base/mergestrategy api/base/m # Set REPO_ROOT for docker-compose export REPO_ROOT := $(shell pwd) +# Local-only credential for the reference web host. Override it for manual +# testing; production authentication is deliberately outside this demo. +export SUBMITQUEUE_WEB_TOKEN ?= test +export SUBMITQUEUE_WEB_CURSOR_SECRET ?= local-demo-cursor-secret + # Which provider the demo stack targets, and the only difference between a free # local run and a live one. Selects a configuration directory rather than a code # path, so adding a provider is mostly adding a directory — see @@ -148,7 +154,7 @@ define assert_clean fi endef -.PHONY: build build-all-linux build-runway-linux build-submitqueue-gateway-client build-submitqueue-gateway-linux build-submitqueue-gateway-server build-submitqueue-orchestrator-linux build-stovepipe-linux build-stovepipe-linux-debug check-gazelle check-mocks check-tidy clean clean-proto demo-requests deps docs-build docs-serve e2e-test fmt gazelle integration-test integration-test-submitqueue-consumer integration-test-extensions integration-test-submitqueue-gateway integration-test-submitqueue-orchestrator license-fix lint lint-binary lint-fmt lint-license local-init-runway-queue-schema local-init-stovepipe-schemas local-runway-start local-runway-stop local-submitqueue-stop local-submitqueue-clean local-submitqueue-gateway-start local-submitqueue-gateway-stop local-init-submitqueue-schemas local-submitqueue-logs local-submitqueue-orchestrator-start local-submitqueue-orchestrator-stop local-submitqueue-ps local-submitqueue-restart local-submitqueue-start local-stop local-stovepipe-debug-start local-stovepipe-logs local-stovepipe-start local-stovepipe-stop mocks proto query-deps query-targets run-client-runway run-client-submitqueue-gateway run-client-submitqueue-orchestrator run-client-stovepipe run-queue-admin test test-no-cache test-race tidy tidy-bazel tidy-go help +.PHONY: build build-all-linux build-runway-linux build-submitqueue-gateway-client build-submitqueue-gateway-linux build-submitqueue-gateway-server build-submitqueue-orchestrator-linux build-stovepipe-linux build-stovepipe-linux-debug check-gazelle check-mocks check-tidy clean clean-proto demo-requests deps docs-build docs-serve e2e-test fmt gazelle integration-test integration-test-submitqueue-consumer integration-test-extensions integration-test-submitqueue-gateway integration-test-submitqueue-orchestrator license-fix lint lint-binary lint-fmt lint-license local-init-runway-queue-schema local-init-stovepipe-schemas local-runway-start local-runway-stop local-submitqueue-stop local-submitqueue-clean local-submitqueue-gateway-start local-submitqueue-gateway-stop local-init-submitqueue-schemas local-submitqueue-logs local-submitqueue-orchestrator-start local-submitqueue-orchestrator-stop local-submitqueue-ps local-submitqueue-restart local-submitqueue-start local-stop local-stovepipe-debug-start local-stovepipe-logs local-stovepipe-start local-stovepipe-stop mocks proto query-deps query-targets run-client-runway run-client-submitqueue-gateway run-client-submitqueue-orchestrator run-client-stovepipe run-queue-admin test test-no-cache test-race tidy tidy-bazel tidy-go web-build web-check web-e2e-test web-image-load web-install web-proto help build: ## Build all services and examples @@ -371,7 +377,7 @@ local-submitqueue-clean: ## Stop the stack and remove its volumes, images, and P @echo "Cleaning all services and data..." @# The overlay is named so that volumes it declares — Runway's checkouts — @# are removed too, rather than surviving as an orphan. - @$(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) down -v --rmi local + @$(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -f $(WEB_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) down -v --rmi local @rm -rf "$(SQ_GIT_SANDBOX_DIR)" @echo "All services, volumes, images, and $(SQ_GIT_SANDBOX_DIR) removed." @@ -460,7 +466,7 @@ local-runway-stop: ## Stop Runway service @echo "Runway services stopped." local-submitqueue-logs: ## View logs from all running services - @$(COMPOSE) -f $(COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) logs -f + @$(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -f $(WEB_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) logs -f local-submitqueue-orchestrator-start: build-submitqueue-orchestrator-linux ## Start Orchestrator service locally (Orchestrator + 2 MySQL databases) @echo "Starting Orchestrator with docker-compose..." @@ -484,11 +490,12 @@ local-submitqueue-orchestrator-stop: ## Stop Orchestrator service local-submitqueue-ps: ## Show running containers and their ports @echo "Running containers and ports:" @echo "" - @$(COMPOSE) -f $(COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) ps + @$(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -f $(WEB_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) ps @echo "" @echo "📡 Service Endpoints:" @echo " Gateway gRPC: localhost:$$(docker port $(SUBMITQUEUE_LOCAL_PROJECT)-gateway-service-1 8080 2>/dev/null | cut -d: -f2 || echo 'not running')" @echo " Orchestrator gRPC: localhost:$$(docker port $(SUBMITQUEUE_LOCAL_PROJECT)-orchestrator-service-1 8080 2>/dev/null | cut -d: -f2 || echo 'not running')" + @echo " Web UX: http://localhost:$$(docker port $(SUBMITQUEUE_LOCAL_PROJECT)-web-service-1 3000 2>/dev/null | cut -d: -f2 || echo 'not running')" @echo "" @echo "🗄️ Database Endpoints:" @echo " MySQL App: localhost:$$(docker port $(SUBMITQUEUE_LOCAL_PROJECT)-mysql-app-1 3306 2>/dev/null | cut -d: -f2 || echo 'not running')" @@ -506,11 +513,11 @@ local-submitqueue-ps: ## Show running containers and their ports local-submitqueue-restart: build-all-linux ## Restart all services (rebuild and restart) @echo "Restarting all services..." - @$(COMPOSE) -f $(COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) restart + @$(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -f $(WEB_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) restart @echo "Services restarted!" @make local-submitqueue-ps -local-submitqueue-start: build-all-linux ## Start full stack (PROVIDER=fake|git|github; github needs GITHUB_TOKEN) +local-submitqueue-start: build-all-linux web-image-load ## Start full stack (PROVIDER=fake|git|github; github needs GITHUB_TOKEN) @echo "Starting full stack against provider '$(PROVIDER)' ($(SQ_PROVIDER_CONFIG_DIR))..." @test -f "$(SQ_PROVIDER_CONFIG_DIR)/merge.yaml" \ || { echo "No such provider '$(PROVIDER)': $(SQ_PROVIDER_CONFIG_DIR)/merge.yaml not found"; exit 2; } @@ -529,13 +536,16 @@ local-submitqueue-start: build-all-linux ## Start full stack (PROVIDER=fake|git| else \ export SQ_CONTAINER_USER=$$(id -u):$$(id -g); \ fi; \ - $(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) up -d --build --wait + $(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -f $(WEB_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) up -d --build --wait @echo "Applying database schemas..." @$(MAKE) -s local-init-submitqueue-schemas @echo "" @echo "✅ Stack is running against provider '$(PROVIDER)'." @echo "" @echo "Gateway gRPC port: $$(docker port $(SUBMITQUEUE_LOCAL_PROJECT)-gateway-service-1 8080 2>/dev/null | cut -d: -f2 || echo 'unknown')" + @echo "Web UX: http://localhost:$$(docker port $(SUBMITQUEUE_LOCAL_PROJECT)-web-service-1 3000 2>/dev/null | cut -d: -f2 || echo 'unknown')" + @echo "Web username: test" + @echo "Web token: $(SUBMITQUEUE_WEB_TOKEN)" @if [ "$(PROVIDER)" = "git" ]; then \ echo "Land target: $(SQ_GIT_SANDBOX_DIR)/sandbox.git"; \ fi @@ -545,7 +555,7 @@ local-submitqueue-start: build-all-linux ## Start full stack (PROVIDER=fake|git| local-submitqueue-stop: ## Stop the SubmitQueue stack (keeps PROVIDER=git's sandbox; the databases do not survive) @echo "Stopping SubmitQueue services..." - @$(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) down + @$(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -f $(WEB_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) down @# Both MySQL services mount anonymous volumes, so `down` detaches them and @# the next `up` creates fresh ones. Saying "data preserved" here would be @# read as "your requests are still there", which they are not. @@ -557,7 +567,7 @@ local-submitqueue-stop: ## Stop the SubmitQueue stack (keeps PROVIDER=git's sand local-stop: ## Stop every local stack — SubmitQueue, Stovepipe, and Runway @echo "Stopping all services..." - @$(COMPOSE) -f $(COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) down + @$(COMPOSE) -f $(COMPOSE_FILE) -f $(PROVIDER_COMPOSE_FILE) -f $(WEB_COMPOSE_FILE) -p $(SUBMITQUEUE_LOCAL_PROJECT) down @$(COMPOSE) -f $(STOVEPIPE_COMPOSE_FILE) -p $(STOVEPIPE_LOCAL_PROJECT) down @$(COMPOSE) -f $(RUNWAY_COMPOSE_FILE) -p $(RUNWAY_LOCAL_PROJECT) down @echo "Services stopped. Anonymous database volumes are not reused on the next start." @@ -687,6 +697,28 @@ tidy-go: ## Run go mod tidy @echo "Running go mod tidy..." @$(BAZEL) run @rules_go//go -- mod tidy -e +web-build: ## Build the web packages and deployable Next.js image with Bazel + @$(BAZEL) build //web/submitqueue:pkg //web/service/submitqueue:image + +web-check: ## Run web generation, packaging, lint, type, unit, and image checks with Bazel + @$(BAZEL) test //web:check --test_output=errors + @$(BAZEL) build //web/service/submitqueue:image + +web-e2e-test: ## Run the Bazel-managed real-stack Playwright and axe web test + @$(BAZEL) test //web/test/e2e:web_test --test_output=errors + +web-image-load: ## Build and load the deployable web image into Docker with Bazel (also tagged with SQ_DOCKER_IMAGE_PREFIX) + @$(BAZEL) run //web/service/submitqueue:image_load + @if [ -n "$$SQ_DOCKER_IMAGE_PREFIX" ] && [ "$$SQ_DOCKER_IMAGE_PREFIX" != "submitqueue" ]; then \ + docker tag submitqueue-web-service:latest "$$SQ_DOCKER_IMAGE_PREFIX-web-service:latest"; \ + fi + +web-install: ## Install the pinned web workspace dependencies + @corepack pnpm@10.17.1 --dir web install --frozen-lockfile + +web-proto: ## Regenerate the committed TypeScript protobuf API with Bazel + @$(BAZEL) run //web/api:generate + help: ## Show this help message @echo "Available targets:" @echo "" diff --git a/REPO.bazel b/REPO.bazel new file mode 100644 index 000000000..d51a46f6f --- /dev/null +++ b/REPO.bazel @@ -0,0 +1 @@ +ignore_directories(["**/node_modules"]) diff --git a/api/base/change/proto/BUILD.bazel b/api/base/change/proto/BUILD.bazel index f7d1e4cf2..681fa4b68 100644 --- a/api/base/change/proto/BUILD.bazel +++ b/api/base/change/proto/BUILD.bazel @@ -1,4 +1,7 @@ exports_files( ["change.proto"], - visibility = ["//tool/proto:__pkg__"], + visibility = [ + "//tool/proto:__pkg__", + "//web/api:__pkg__", + ], ) diff --git a/api/base/mergestrategy/proto/BUILD.bazel b/api/base/mergestrategy/proto/BUILD.bazel index cca226aff..7d5ca5b48 100644 --- a/api/base/mergestrategy/proto/BUILD.bazel +++ b/api/base/mergestrategy/proto/BUILD.bazel @@ -1,4 +1,7 @@ exports_files( ["mergestrategy.proto"], - visibility = ["//tool/proto:__pkg__"], + visibility = [ + "//tool/proto:__pkg__", + "//web/api:__pkg__", + ], ) diff --git a/doc/howto/DEVELOPMENT.md b/doc/howto/DEVELOPMENT.md index a37cca1df..b7f45f968 100644 --- a/doc/howto/DEVELOPMENT.md +++ b/doc/howto/DEVELOPMENT.md @@ -108,6 +108,10 @@ CI runs `make lint`, `make check-tidy`, and `make check-gazelle`. `make lint` in | `make gazelle` | Update `BUILD.bazel` files | | `make mocks` | Regenerate mockgen files | | `make check-mocks` | Fail if generated mocks are stale | +| `make web-build` | Build the web packages and deployable OCI image with Bazel | +| `make web-check` | Run web generation drift, package, lint, type, unit, and production image checks with Bazel | +| `make web-e2e-test` | Run the Bazel-managed real-stack Playwright and axe test | +| `make web-proto` | Regenerate the committed TypeScript protobuf API with Bazel | | `make proto` | Regenerate protobuf files | | `make clean` | Remove the Bazel cache and `bin/` | | `make clean-proto` | Remove generated protobuf Go files | @@ -118,6 +122,8 @@ CI runs `make lint`, `make check-tidy`, and `make check-gazelle`. `make lint` in | `make local-stop` | Stop SubmitQueue, Stovepipe, and Runway | | `make help` | List every target | +The web build does not require a host Node.js or pnpm installation: Bazel supplies Node, npm packages, Buf, TypeScript, Next.js, Playwright, Chromium, and the container image toolchain. `make web-install` is an optional convenience for editors and direct pnpm development only. + ## Running Specific Tests ```bash diff --git a/doc/howto/QUICKSTART.md b/doc/howto/QUICKSTART.md index 1ee40c445..23721e845 100644 --- a/doc/howto/QUICKSTART.md +++ b/doc/howto/QUICKSTART.md @@ -22,7 +22,7 @@ The queue's own logic is real on every rung; what changes is how much of the wor make local-submitqueue-start ``` -This builds the Linux binaries, brings up Gateway, Orchestrator, Runway and two MySQL databases, and applies their schemas. The first run spends most of its time in the Bazel build; later ones start in seconds. +This uses Bazel to build the Linux binaries and load the web OCI image, brings up Gateway, Orchestrator, Runway, the web host, and two MySQL databases, and applies their schemas. The first run spends most of its time in the Bazel build; later ones start in seconds. Compose publishes each service on a **random** host port so several stacks can run side by side, which means there is no fixed address to hard-code. The start-up output ends with the ports, and `make local-submitqueue-ps` prints them again at any time: @@ -30,10 +30,15 @@ Compose publishes each service on a **random** host port so several stacks can r ✅ Stack is running against provider 'fake'. Gateway gRPC port: 58537 +Web UX: http://localhost:58538 +Web username: test +Web token: test ``` You do not have to note it down. Every command below finds the running stack's port for itself, which matters because Compose picks a fresh one on every start — a number copied from an earlier run is the most common reason a demo command cannot connect. Set `GATEWAY_ADDR=host:port` only to reach a gateway this Makefile did not start. +The Web UX is a read-only reference host for the same gateway. Run `make demo-requests`, then open the printed URL with username `test` and password `test` and select a row to see its current status and retained lifecycle history. The list uses a rolling 24-hour receipt window; Refresh includes newly created traffic without adding time bounds to the URL. The host and its default credentials are deliberately local-demo wiring, not a production authentication design. Override the password with `SUBMITQUEUE_WEB_TOKEN= make local-submitqueue-start` when desired. + ## Put traffic through it ```bash diff --git a/doc/howto/TESTING.md b/doc/howto/TESTING.md index 167e5bc2c..b2c649c6a 100644 --- a/doc/howto/TESTING.md +++ b/doc/howto/TESTING.md @@ -59,6 +59,7 @@ make integration-test # All integration tests # E2E tests (Docker required) make e2e-test +make web-e2e-test # Build make build # Build all targets @@ -84,6 +85,13 @@ make build-all-linux # Build Linux binaries for the local docker- - Containers: Each suite's required services and dependencies; SubmitQueue E2E includes Gateway, Orchestrator, Runway, and MySQL - Tests end-to-end behavior, including cross-service communication where applicable +**4. Web E2E Test** - Browser behavior against the complete SubmitQueue stack +- Location: `web/test/e2e/` +- Run: `make web-e2e-test` +- Containers: Bazel-built Gateway, Orchestrator, Runway, web OCI image, and MySQL +- Coverage: Basic authentication, h2c gateway access, newest-first queue requests, encoded SQID navigation, ordered history, and axe accessibility checks +- Tooling: Bazel supplies Node, Playwright, and Chromium; no host Node install or browser download is required + ### Live GitHub Tests Two suites land real pull requests on github.com: `TestGitHubLandE2E` (in `make e2e-test`) and the Runway GitHub merger extension test (`make integration-test-runway-merger`). They run only when `SQ_GITHUB_TOKEN` and `SQ_GITHUB_TEST_REPO=owner/repo` are set, and skip otherwise, so every other run is unaffected. The token needs write access to the test repository; everything the tests open is named under `sq-it/` and removed afterwards, while what they merge stays on its default branch. @@ -107,13 +115,15 @@ Every run merges a few small files under `sq-it/` into the test repository's def ### How Automated Tests Work -Tests use **docker-compose** via `ComposeStack` to spin up containers automatically: +Go integration and domain E2E tests use **docker-compose** via `ComposeStack` to spin up containers automatically: 1. `NewComposeStack()` registers cleanup (stop log tailing, tear down containers) 2. `Up()` starts containers, waits for healthchecks (`--wait`), and auto-tails container logs to stderr 3. Tests run against those containers with **real-time log output** 4. On cleanup, containers are torn down automatically (set `SKIP_CLEANUP=true` to keep them for inspection) +The web E2E target uses the Bazel `py_test` runner in `web/test/e2e/run_e2e.py`. It stages runfiles into a temporary Compose context, loads the Bazel-built web image, applies the gateway and orchestrator schemas, runs Playwright, and removes its `e2e-submitqueue-web-` project unless `SKIP_CLEANUP=true`. It does not auto-tail service logs; inspect retained containers with `docker ps` and `docker logs` when cleanup is disabled. + --- ## Container Naming diff --git a/doc/rfc/image/web-ui/change-submission-history.jpg b/doc/rfc/image/web-ui/change-submission-history.jpg new file mode 100644 index 000000000..95fa91a90 Binary files /dev/null and b/doc/rfc/image/web-ui/change-submission-history.jpg differ diff --git a/doc/rfc/image/web-ui/event-history.jpg b/doc/rfc/image/web-ui/event-history.jpg new file mode 100644 index 000000000..1926aa747 Binary files /dev/null and b/doc/rfc/image/web-ui/event-history.jpg differ diff --git a/doc/rfc/image/web-ui/queue-landing.jpg b/doc/rfc/image/web-ui/queue-landing.jpg new file mode 100644 index 000000000..abccf41f3 Binary files /dev/null and b/doc/rfc/image/web-ui/queue-landing.jpg differ diff --git a/doc/rfc/image/web-ui/request-summary.jpg b/doc/rfc/image/web-ui/request-summary.jpg new file mode 100644 index 000000000..9255f009f Binary files /dev/null and b/doc/rfc/image/web-ui/request-summary.jpg differ diff --git a/doc/rfc/index.md b/doc/rfc/index.md index 8ea2c2881..02f012002 100644 --- a/doc/rfc/index.md +++ b/doc/rfc/index.md @@ -12,6 +12,7 @@ Design documents and technical proposals, grouped by scope. Shared/cross-cutting - [Change URIs](change-uri.md) - Identity of a code change: `scheme://{host[:port]}/{path}` per provider (GitHub PR, Phabricator Diff, git ref/commit) and canonical-form rules - [Hooks Framework](hook-framework.md) - Implemented fire-and-forget side effects: one shared `HookEvent` contract (`api/base/hook/`) on a durable per-domain hook topic, dispatched by `platform/hook` to `platform/extension/hook`. Stovepipe `process` and `record` publish repository events; the SubmitQueue orchestrator registers the stage and does not publish events yet - [Service-Scoped Extensions](service-scoped-extensions.md) - Implemented for SubmitQueue storage: gateway and orchestrator aggregates, schemas, and the core packages that serve one service have moved, while store contracts stay at `submitqueue/extension/storage`. Domain-level `buildrunner`, `conflict`, and `speculation` have not moved, and `changeset` still declares its own store slice +- [Web UI as a Mountable Module](web-ui.md) - Shipping the read-only SubmitQueue UI as a module any React server host mounts, while the host owns identity, gateway transport, telemetry, and its framework ## SubmitQueue diff --git a/doc/rfc/web-ui.md b/doc/rfc/web-ui.md new file mode 100644 index 000000000..56686f9f6 --- /dev/null +++ b/doc/rfc/web-ui.md @@ -0,0 +1,142 @@ +# Web UI as a Mountable Module + +## Decision + +SubmitQueue publishes its browser UX as a mountable module: routing, data loading, page models, pages, layout, styling, navigation, and polling ship together, so a host renders the complete experience rather than rebuilding it. A deployer-owned host, in any React-capable server framework, supplies identity, gateway transport, cursor signing keys, logging, metrics, tracing, and its framework's navigation. + +The module resembles `submitqueue/client/`, and each host resembles service wiring. The module consumes the published gateway contract structurally and maps it to presentation. Domain extensions stay behind the gateway. + +Phase one is read-only: request summary, history, and queue receipt list ([status/list](submitqueue/status-list-api.md), [history](submitqueue/history-api.md)). Mutations require a separate authorization and audit design. + +## Architecture + +Deployers already run web platforms of their own, with their own server framework, React major version, RPC client, protobuf runtime, and identity and telemetry. A single configurable application, as cadence-web is, cannot run inside such a platform, and anything deeper than configuration means a fork. The UI therefore ships as a module that any React server host mounts. The module owns the user experience; the host owns infrastructure. + +``` +host web/service/submitqueue routes, framework adapter, wiring (the main.go counterpart) + │ mounts +module web/submitqueue routing, controllers, pages, styles for SubmitQueue +``` + +How a page is served: + +1. The host routes every path to one catch-all page and authenticates the viewer. +2. The host calls the module's single `handle` entry with the path. The module strips its mount path, parses the still-encoded rest into a route, applies the host's optional authorization, assigns a request ID, and opens a span and a duration metric. +3. The module loads the route through its controllers and the gateway client the host injected. +4. The module returns a redirect, a not-found or forbidden decision, or a serializable page model; expected outcomes are never exceptions. +5. The host renders the model with the module's client app. In the browser, the app routes links, refresh, and polling through the host's router. + +The boundaries are enforced, not just documented. The module imports no generated protobuf code, protobuf runtime, transport, or web framework, and its client entry never reaches its controllers or Node APIs. Only the host chooses implementations, including its gateway transport and its framework adapter. A package test scans every file in the built tarball for forbidden imports, and Bazel visibility keeps web targets inside `web/` and the generated bindings inside the host and the end-to-end test. + +## Responsibilities + +| Module | Host | +|---|---| +| Routing in both directions, catalog validation, request and change lookup, receipt windows, pagination, internal links | One catch-all route that hands the still-encoded path to the host pipeline | +| Serializable page models with titles, complete pages, shell, the stylesheet and theme tokens, status and error states | Authentication, the principal it produces, and optional per-route authorization | +| Client navigation, refresh, and polling policy, expressed through a navigation adapter | The adapter: the framework's link, push, and refresh; the host owns the page model and history | +| The gateway contract, default error classification, cursor format, and instrumentation points | Gateway transport and credentials, cursor signing keys, logger, meter, tracer, configuration, and deployment | + +Components render serializable props and never fetch; sessions, gateway clients, and protobuf messages stay on the server. A host that supplies no navigation adapter gets ordinary document navigation and manual refresh. The host loads the module's one self-contained stylesheet, by importing it or by serving the file and linking to it. The module caches nothing unless the host configures a catalog cache keyed by viewer, and the host resolves the gateway per request when visibility depends on the viewer. + +## URLs + +The queue is the top-level navigation context. The directory lists the queues the gateway reports through `ListQueues`, including empty ones, rather than inferring queues from received requests. + +| Page | Route | Example | Lookup | +|---|---|---|---| +| Queue directory | `/` | `/` | `ListQueues` | +| Queue and request list | `/` | `/demo-queue` | Rolling 24-hour receipt window | +| Request summary | `//request/` | `/demo-queue/request/10` | By request ID | +| Request history | `//request/?view=history` | `/demo-queue/request/10?view=history` | By request ID | +| GitHub change | `//change/github////pull/[/]` | `/demo-queue/change/github/github.com/uber/submitqueue/pull/123` | Exact URI when pinned to a SHA; otherwise the receipt window | +| Phabricator change | `//change/phab//D[/]` | `/demo-queue/change/phab/phabricator.example.com/D12345` | Exact URI when pinned to a diff; otherwise the receipt window | +| Git ref change | `//change/git///[/]` | `/demo-queue/change/git/git.example.com/demo/refs%2Fheads%2Fmain` | The receipt window | +| Batch detail | `//batch/` | `/demo-queue/batch/4` | Deferred until a batch read contract exists | + +Request IDs follow [queue-scoped resource IDs](submitqueue/workflow.md#resource-ids): the route supplies the queue and resource kind, and the ID passes to the gateway unchanged. IDs stay readable and opaque in the path, and a dot-only segment gets a visible `~` escape so browsers cannot normalize it away. + +Change pages mirror the [change URI](change-uri.md): replace `://` with `//change//`, keeping the authority and encoded path, with no `commit` or `diff` segments. Omitting the version shows every submitted version; including it pins one. Receipt-window scans read a bounded number of queue pages per request and continue through an opaque `?page=` cursor, and provider-only hints in change URIs never reach the URL. + +Summary is the default request view, and `?view=history` makes History shareable. Queue URLs carry no timestamps: `/demo-queue` always shows the live window, and `/demo-queue?page=` is a snapshot of an older page. + +## Behavior + +**Windows and pagination.** `List` takes a queue and a half-open receipt window. The module recalculates the trailing 24-hour window on refresh and keeps timestamps out of the URL. Older pages carry the original bounds in an opaque cursor, signed under a scope that names the page, so a cursor cannot be replayed elsewhere. The reference host signs with HMAC under a key separate from any viewer credential. Refreshing an older page returns to the live first page. + +**Refresh and polling.** A refresh does not schedule the next until it has finished rendering. The wait is the poll interval plus jitter, grows across consecutive transport failures, and pauses while the document is hidden or the browser is offline. A request view stops once the summary is terminal and loaded history contains the same terminal status, or when a terminal summary pairs with a non-retryable history error that polling cannot fix. A queue list keeps polling for the life of its window. A change page that scans the receipt window refreshes only on request, because each render reads up to ten queue pages; a pinned version, found by one exact lookup, keeps polling. + +**Errors and values.** Gateway failures map from gRPC status: invalid argument and resource exhausted are user errors, not found is a missing resource, unavailable and deadline exceeded are transient, and everything else is internal. A transport with another error shape supplies its own classifier. Raw gateway messages are logged, never rendered, and views keep the last good data on transient failures. Status values stay strings with a display table and an unknown fallback. `int64` values arrive as bigint, number, string, or `Long` and become milliseconds after a safe-range check. + +**Theme.** The `--sq-*` custom properties on `.sq-app` are the theming contract, with light, dark, and system themes built in. React 18 and 19 are both supported. + +## Observability and identity + +Logging, metrics, and tracing are injected the way the Go services take zap and tally. The module accepts a pino logger, an OpenTelemetry meter, and an OpenTelemetry tracer, and default to a silent logger and the OpenTelemetry API's no-op providers. Every gateway read is timed and traced, and a failure is logged once under pino's `err` key with the request ID. The module times page loads too. The reference host logs with pino and exposes Prometheus metrics, from startup, only when a port is configured. It registers no tracer provider, so its spans are no-ops; a host that registers OpenTelemetry tracing can also add a gateway interceptor that propagates trace context. A host with another metrics client adapts it to the meter interface; if such adapters prove large, a smaller tally-shaped interface may replace it. + +Identity has no shared library to depend on, so the host authenticates and passes an opaque principal. The module never sees credentials. + +## Generated bindings + +The repository publishes `.proto` sources. The module depends only on the structural shape of a gateway client, so a Protobuf-ES and Connect client satisfies it directly and any other client satisfies it through a thin adapter; a deployer with its own toolchain vendors the protos and generates its own bindings. Inside the repository, private generated TypeScript serves the reference host's gateway client and the end-to-end test, with a drift test against the sources, the same way Go's bindings serve both its server and its tests. + +## Layout + +The workspace lives under `web/` and repeats the Go root layout; the UI is a gateway client rather than a Go service, so the domain uses the single-service shape. Bazel owns the Node toolchain, dependency graph, generated bindings, compilation, tests, Next.js production build, OCI image, and browser E2E; the pnpm workspace exists for editors, and Gazelle excludes `web/`. + +| Go | Web | Holds | +|---|---|---| +| `{domain}/entity`, `core`, `extension`, `controller` | `web/submitqueue/` | View models and load results, paths and routing, observability helpers, the gateway and cursor contracts with their fakes and HMAC codec, per-page controllers, plus `view/` for the app, pages, building blocks, and the stylesheet | +| `api/{domain}/{service}` | `web/api/` | Private generated bindings for the host and the E2E test | +| `service/{domain}/{service}` | `web/service/submitqueue/` | The reference host: `src/server` (wiring, free of Next.js), `src/next` (the Next.js adapter), `src/app` (routes) | + +## Acceptance + +- Unit tests cover route round-trips, readable paths, timestamp conversion, stable list bounds, cursor scoping and tampering, error classification and host classifiers, catalog caching, request-scoped logging, gateway and page metrics, mount paths and authorization, client navigation, and polling (transition-aware single flight, progressive backoff, hidden and offline pause, history-aware terminal stop). +- Every protected host layout and route repeats the authentication check rather than relying only on the Next.js proxy. +- `//web/test/e2e:web_test` runs the Bazel-built Go services, the Bazel-built web image, and a digest-pinned MySQL image with pulls and Dockerfile builds disabled. It covers the Basic-auth challenge, explicit h2c, newest-first lists, client-side navigation, a slash-containing request ID, lifecycle and build history, paginated change history, and axe checks. +- Built package tarballs are inspected for contents and forbidden imports, typechecked by a consumer written only against public entry points, rendered by a plain Node consumer through `handle`, and server-rendered and hydrated under React 18. + +## Deferred + +- Trusted change links and typed build-history links wait for a host-owned allowlist and a gateway field that distinguishes URLs from display metadata. +- TLS transport E2E, responsive screenshot matrices, focus and scroll restoration, and the full React and Next.js version matrix are follow-up coverage. + +## Rejected + +- **A single deployable application customized by configuration (cadence-web's model).** It cannot run inside a deployer's existing web platform, and customization beyond configuration means forking. +- **A shared platform package before a second module exists.** Splitting domain-free contracts and React building blocks into their own package adds a module registry, a second package boundary, and a stylesheet merge for a single consumer. They live in the module until another web module needs them. +- **Framework adapters in the module.** Each host owns its framework integration; the module stays framework-free, and the Next.js adapter lives with the reference host. +- **Logging, metrics, or authentication as module extensions.** Hosts already own those backends; the module accepts standard types instead, as the Go services accept zap and tally. +- **Client-side model fetching in the module.** Hosts' routers already own history and data loading, so the module asks the host to refresh or navigate instead of fetching page models itself. +- **An inline stylesheet for hosts that cannot import CSS.** Such hosts can serve the shipped file and link to it, which costs nothing extra. +- **A production application contract owned by this repository.** The host and OCI image are a local reference; a production deployer owns identity, telemetry, transport, and release policy. +- **Static export or embedding in a Go binary.** Request routes are dynamic, and the gateway stays private to a server that can authorize the caller. + +## Appendix: reference UX + +Design mockups with illustrative data. The implementation shows gateway change URIs rather than placeholder titles, and the mockups' slash-containing request IDs predate the scoped-ID contract above. + +### Queue landing page + +![Queue landing mockup showing a newest-first request table, status badges, displayed-page search, receipt window, and live refresh state.](image/web-ui/queue-landing.jpg) + +Newest-first request table with displayed-page search, status, receipt times, and refresh controls. + +### Request summary + +![Request summary mockup showing an opaque request ID, current status, copy controls, Summary and History tabs, request facts, changes, and expandable metadata.](image/web-ui/request-summary.jpg) + +Request identity and current status, with copy controls, changes, and expandable metadata. + +### Request history + +![Request history mockup showing timestamped lifecycle and build events, an event-type filter, and expanded build metadata.](image/web-ui/event-history.jpg) + +Chronological lifecycle and build events with type filters, errors, and expandable metadata. + +### Change submission history + +![Change submission history mockup showing multiple SubmitQueue requests for GitHub PR 123 across two pinned versions, with receipt times, statuses, and a displayed-version filter.](image/web-ui/change-submission-history.jpg) + +Submissions for a PR or revision, with version selection and separate status/history links for each request. diff --git a/service/runway/server/BUILD.bazel b/service/runway/server/BUILD.bazel index 8ce2fc7c3..69e1adda3 100644 --- a/service/runway/server/BUILD.bazel +++ b/service/runway/server/BUILD.bazel @@ -65,6 +65,7 @@ go_cross_binary( name = "runway_linux", platform = "@rules_go//go/toolchain:linux_amd64", target = ":runway", + visibility = ["//web/test/e2e:__pkg__"], ) # Everything a Docker-based test needs to build the runway image from a staged @@ -77,7 +78,10 @@ filegroup( "Dockerfile", ":runway_linux", ], - visibility = ["//test:__subpackages__"], + visibility = [ + "//test:__subpackages__", + "//web/test/e2e:__pkg__", + ], ) go_test( diff --git a/service/submitqueue/BUILD.bazel b/service/submitqueue/BUILD.bazel index 710069cd1..ffe271e43 100644 --- a/service/submitqueue/BUILD.bazel +++ b/service/submitqueue/BUILD.bazel @@ -1,7 +1,9 @@ exports_files( [ + "docker-compose.fake.yml", "docker-compose.git.yml", "docker-compose.provider.yml", + "docker-compose.web.yml", "docker-compose.yml", ], visibility = ["//visibility:public"], diff --git a/service/submitqueue/demo/BUILD.bazel b/service/submitqueue/demo/BUILD.bazel index f3fe36235..e4a684b51 100644 --- a/service/submitqueue/demo/BUILD.bazel +++ b/service/submitqueue/demo/BUILD.bazel @@ -1,5 +1,11 @@ load("@rules_go//go:def.bzl", "go_library", "go_test") +filegroup( + name = "fake_provider_config", + srcs = glob(["provider/fake/*.yaml"]), + visibility = ["//web/test/e2e:__pkg__"], +) + go_library( name = "go_default_library", srcs = [ diff --git a/service/submitqueue/docker-compose.web.yml b/service/submitqueue/docker-compose.web.yml new file mode 100644 index 000000000..ad8450557 --- /dev/null +++ b/service/submitqueue/docker-compose.web.yml @@ -0,0 +1,33 @@ +# Compose overlay for the local, read-only SubmitQueue web demo. +# +# The browser talks only to this Next.js host. The host reaches the private +# grpc-go gateway over Compose's internal network using explicit plaintext +# HTTP/2, which is intentionally limited to the local demo. + +services: + web-service: + image: ${SQ_DOCKER_IMAGE_PREFIX:-submitqueue}-web-service:latest + platform: linux/amd64 + ports: + - "3000" + environment: + - PORT=3000 + - HOSTNAME=0.0.0.0 + - SUBMITQUEUE_GATEWAY_URL=http://gateway-service:8080 + - SUBMITQUEUE_GATEWAY_ALLOW_PLAINTEXT=true + - SUBMITQUEUE_WEB_TOKEN=${SUBMITQUEUE_WEB_TOKEN:?set SUBMITQUEUE_WEB_TOKEN} + - SUBMITQUEUE_WEB_CURSOR_SECRET=${SUBMITQUEUE_WEB_CURSOR_SECRET:?set SUBMITQUEUE_WEB_CURSOR_SECRET} + depends_on: + gateway-service: + condition: service_started + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://127.0.0.1:3000/healthz').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))", + ] + interval: 2s + timeout: 2s + retries: 30 diff --git a/service/submitqueue/gateway/server/BUILD.bazel b/service/submitqueue/gateway/server/BUILD.bazel index 0ff81f653..a809e831f 100644 --- a/service/submitqueue/gateway/server/BUILD.bazel +++ b/service/submitqueue/gateway/server/BUILD.bazel @@ -5,6 +5,11 @@ exports_files( visibility = ["//visibility:public"], ) +exports_files( + ["queues.yaml"], + visibility = ["//web/test/e2e:__pkg__"], +) + go_library( name = "gateway_lib", srcs = ["main.go"], @@ -55,6 +60,7 @@ go_cross_binary( name = "gateway_linux", platform = "@rules_go//go/toolchain:linux_amd64", target = ":gateway", + visibility = ["//web/test/e2e:__pkg__"], ) # Everything a Docker-based test needs to build the gateway image from a staged @@ -68,7 +74,10 @@ filegroup( "queues.yaml", ":gateway_linux", ], - visibility = ["//test:__subpackages__"], + visibility = [ + "//test:__subpackages__", + "//web/test/e2e:__pkg__", + ], ) go_test( diff --git a/service/submitqueue/orchestrator/server/BUILD.bazel b/service/submitqueue/orchestrator/server/BUILD.bazel index 5bd25680f..c4b4d887e 100644 --- a/service/submitqueue/orchestrator/server/BUILD.bazel +++ b/service/submitqueue/orchestrator/server/BUILD.bazel @@ -91,6 +91,7 @@ go_cross_binary( name = "orchestrator_linux", platform = "@rules_go//go/toolchain:linux_amd64", target = ":orchestrator", + visibility = ["//web/test/e2e:__pkg__"], ) # Everything a Docker-based test needs to build the orchestrator image from a @@ -103,7 +104,10 @@ filegroup( "Dockerfile", ":orchestrator_linux", ], - visibility = ["//test:__subpackages__"], + visibility = [ + "//test:__subpackages__", + "//web/test/e2e:__pkg__", + ], ) go_test( diff --git a/tool/linter/binaryfile/main.go b/tool/linter/binaryfile/main.go index ec5fe3ad5..5a991ca1f 100644 --- a/tool/linter/binaryfile/main.go +++ b/tool/linter/binaryfile/main.go @@ -44,12 +44,14 @@ import ( // text header an executable format might begin with. const sniffLen = 8000 -// allowed lists tracked paths that are legitimately binary, relative to the -// repository root. It is empty because nothing in the repository is: the tree -// is source, schemas, and generated Go. An entry belongs here only when a -// binary genuinely has to be versioned — a test fixture that cannot be built, -// or an image a document renders — never to silence a stray build artifact. -var allowed = map[string]bool{} +// Only explicitly versioned document images and non-buildable fixtures are +// exempt; generated binaries and stray build outputs remain violations. +var allowed = map[string]bool{ + "doc/rfc/image/web-ui/change-submission-history.jpg": true, + "doc/rfc/image/web-ui/event-history.jpg": true, + "doc/rfc/image/web-ui/queue-landing.jpg": true, + "doc/rfc/image/web-ui/request-summary.jpg": true, +} // violation is one tracked file that is binary. type violation struct { diff --git a/web/.nvmrc b/web/.nvmrc new file mode 100644 index 000000000..442c7587a --- /dev/null +++ b/web/.nvmrc @@ -0,0 +1 @@ +22.20.0 diff --git a/web/BUILD.bazel b/web/BUILD.bazel new file mode 100644 index 000000000..3f32bb61b --- /dev/null +++ b/web/BUILD.bazel @@ -0,0 +1,70 @@ +load("@aspect_rules_js//js:defs.bzl", "js_library") +load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project") +load("@npm//:defs.bzl", "npm_link_all_packages") + +package(default_visibility = ["//visibility:public"]) + +npm_link_all_packages(name = "node_modules") + +test_suite( + name = "check", + tests = [ + "//web/api:generate_tests", + "//web/api:test", + "//web/test/package:consumer_typecheck_typecheck_test", + "//web/test/package:consumer_runtime_test", + "//web/test/package:pack_contents_test", + "//web/test/package/react18:react18_runtime_test", + "//web/service/submitqueue:lint", + "//web/service/submitqueue:test", + "//web/service/submitqueue:typecheck_typecheck_test", + "//web/submitqueue:test", + "//web/submitqueue:typecheck_typecheck_test", + ":e2e_typecheck_typecheck_test", + ], +) + +js_library( + name = "vitest_config", + srcs = ["vitest.config.ts"], + deps = [":node_modules/vitest"], +) + +exports_files([ + "package.json", + "playwright.config.ts", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "tsconfig.e2e.json", + "vitest.config.ts", +]) + +ts_config( + name = "tsconfig_base", + src = "tsconfig.base.json", +) + +ts_config( + name = "tsconfig_e2e", + src = "tsconfig.e2e.json", + deps = [":tsconfig_base"], +) + +ts_project( + name = "e2e_typecheck", + srcs = [ + "playwright.config.ts", + "//web/test/e2e:srcs_for_typecheck", + ], + declaration = True, + no_emit = True, + tsconfig = ":tsconfig_e2e", + deps = [ + ":node_modules/@axe-core/playwright", + ":node_modules/@connectrpc/connect", + ":node_modules/@connectrpc/connect-node", + ":node_modules/@playwright/test", + ":node_modules/@types/node", + ":node_modules/@submitqueue/api", + ], +) diff --git a/web/README.md b/web/README.md new file mode 100644 index 000000000..d4c9f25d5 --- /dev/null +++ b/web/README.md @@ -0,0 +1,42 @@ +# SubmitQueue web + +A read-only browser UI for SubmitQueue, shipped as a **module** any server can mount. The module owns the whole UX (routing, data loading, pages, styles, navigation, polling); the **host** that mounts it owns infrastructure (identity, gateway transport, logging, metrics, framework). Design decisions are in [the RFC](../doc/rfc/web-ui.md). + +## Layers + +``` +service/submitqueue the host: Next.js app + wiring (like a Go service's main.go) + │ mounts +submitqueue the SubmitQueue module: routes, controllers, pages, styles +``` + +Dependencies only point down. The module never imports generated protos, a transport, or a web framework; only the host chooses implementations. A package test checks this against every file in the built package. + +## How a page is served + +1. Next.js routes every path to one catch-all page in the host. +2. The host authenticates the viewer and calls the module's `handle(path)`. +3. The module strips its mount path, parses the rest into a route, applies the host's optional authorization, and loads the route through its controllers and the gateway client the host injected. +4. The result is a redirect, not-found, forbidden, or a serializable page model; the host renders the model with `SubmitQueueApp`. +5. In the browser, `SubmitQueueApp` handles links, refresh, and polling through the host's navigation adapter. + +## Layout + +| Path | What it is | +|---|---| +| [`submitqueue/`](submitqueue/) | `@submitqueue/web-submitqueue`: the module, laid out like a Go domain (`entity`, `core`, `extension`, `controller`) plus `view` | +| [`service/submitqueue/`](service/submitqueue/) | The reference host: `src/server` (wiring), `src/next` (Next.js adapter), `src/app` (routes), and its OCI image | +| [`api/`](api/) | Private generated gateway bindings, used by the host and the end-to-end test | +| [`test/`](test/) | End-to-end browser test and package checks | +| [`tool/`](tool/) | Bazel rule for protobuf generation | + +## Build, test, run + +Bazel is the build (it pins Node 22); the pnpm workspace exists only for editors (`make web-install`). + +```bash +make web-check # unit, type, lint, drift, and package checks +make web-e2e-test # real stack in Docker + Playwright + axe +make local-submitqueue-start # full local stack; prints the web URL (user test, password test) +make web-proto # after changing gateway .proto files +``` diff --git a/web/api/BUILD.bazel b/web/api/BUILD.bazel new file mode 100644 index 000000000..1cd5bd4e6 --- /dev/null +++ b/web/api/BUILD.bazel @@ -0,0 +1,94 @@ +load("@aspect_rules_js//npm:defs.bzl", "npm_package") +load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project") +load("@bazel_lib//lib:write_source_files.bzl", "write_source_files") +load("@npm//:defs.bzl", "npm_link_all_packages") +load("@npm//web:@bufbuild/buf/package_json.bzl", buf = "bin") +load("@npm//web:@bufbuild/protoc-gen-es/package_json.bzl", protoc_gen_es = "bin") +load("@npm//web:vitest/package_json.bzl", vitest = "bin") +load("//web/tool:generate_api.bzl", "generate_web_api") + +# Generated bindings serve only the reference host and the end-to-end tests; +# reusable packages consume the gateway structurally. +package(default_visibility = [ + "//web:__pkg__", + "//web/service/submitqueue:__pkg__", + "//web/test/e2e:__pkg__", +]) + +npm_link_all_packages(name = "node_modules") + +buf.buf_binary(name = "buf") + +protoc_gen_es.protoc_gen_es_binary(name = "protoc_gen_es") + +generate_web_api( + name = "generated", + buf = ":buf", + protoc_gen_es = ":protoc_gen_es", + srcs = [ + "//api/base/change/proto:change.proto", + "//api/base/mergestrategy/proto:mergestrategy.proto", + "//api/submitqueue/gateway/proto:gateway.proto", + ], +) + +write_source_files( + name = "generate", + diff_test_failure_message = "Generated web API is stale. Run bazel run //web/api:generate.", + files = {"src/gen": ":generated"}, +) + +filegroup( + name = "generated_srcs", + srcs = glob(["src/gen/**/*.ts"]), +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", + deps = ["//web:tsconfig_base"], +) + +ts_config( + name = "tsconfig_build", + src = "tsconfig.build.json", + deps = [":tsconfig"], +) + +ts_project( + name = "lib", + srcs = [":generated_srcs"], + declaration = True, + no_emit = False, + out_dir = "dist", + root_dir = "src/gen", + source_map = True, + transpiler = "tsc", + tsconfig = ":tsconfig_build", + deps = [":node_modules/@bufbuild/protobuf"], +) + +npm_package( + name = "pkg", + package = "@submitqueue/api", + srcs = [ + ":lib", + ":lib_types", + "README.md", + "package.json", + ], + version = "0.1.0", +) + +vitest.vitest_test( + name = "test", + args = ["run"], + chdir = package_name(), + data = [ + ":generated_srcs", + ":node_modules", + "//web:vitest_config", + "//web:node_modules/vitest", + "src/api.test.ts", + ], +) diff --git a/web/api/README.md b/web/api/README.md new file mode 100644 index 000000000..776c402e4 --- /dev/null +++ b/web/api/README.md @@ -0,0 +1,8 @@ +# @submitqueue/api + +Private Protobuf-ES bindings for the gateway protos, committed with a drift test. Two consumers, like Go's `protopb` serving both server and tests: the reference host's gateway client and the end-to-end test. The reusable packages never import it; they accept any client that matches their structural gateway contract. + +```bash +make web-proto # regenerate after changing .proto files +./tool/bazel test //web/api:generate_test //web/api:test +``` diff --git a/web/api/package.json b/web/api/package.json new file mode 100644 index 000000000..02e08eee1 --- /dev/null +++ b/web/api/package.json @@ -0,0 +1,32 @@ +{ + "name": "@submitqueue/api", + "version": "0.1.0", + "private": true, + "type": "module", + "files": [ + "dist" + ], + "exports": { + "./base/change": { + "types": "./dist/api/base/change/proto/change_pb.d.ts", + "import": "./dist/api/base/change/proto/change_pb.js" + }, + "./base/mergestrategy": { + "types": "./dist/api/base/mergestrategy/proto/mergestrategy_pb.d.ts", + "import": "./dist/api/base/mergestrategy/proto/mergestrategy_pb.js" + }, + "./submitqueue/gateway": { + "types": "./dist/api/submitqueue/gateway/proto/gateway_pb.d.ts", + "import": "./dist/api/submitqueue/gateway/proto/gateway_pb.js" + } + }, + "scripts": { + "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc --project tsconfig.build.json", + "lint": "tsc --project tsconfig.json", + "test": "vitest run", + "typecheck": "tsc --project tsconfig.json" + }, + "dependencies": { + "@bufbuild/protobuf": "2.15.0" + } +} diff --git a/web/api/src/api.test.ts b/web/api/src/api.test.ts new file mode 100644 index 000000000..74439b1d8 --- /dev/null +++ b/web/api/src/api.test.ts @@ -0,0 +1,23 @@ +import { create } from "@bufbuild/protobuf"; +import { describe, expect, it } from "vitest"; + +import { + ListRequestSchema, + SubmitQueueGateway, +} from "./gen/api/submitqueue/gateway/proto/gateway_pb.js"; + +describe("generated gateway API", () => { + it("constructs list requests without losing int64 values", () => { + const request = create(ListRequestSchema, { + queue: "demo-queue", + receivedAtOrAfterMs: 1_700_000_000_000n, + receivedBeforeMs: 1_700_086_400_000n, + pageSize: 50, + }); + + expect(request.queue).toBe("demo-queue"); + expect(request.receivedAtOrAfterMs).toBe(1_700_000_000_000n); + expect(request.receivedBeforeMs).toBe(1_700_086_400_000n); + expect(SubmitQueueGateway.methods.some((method) => method.localName === "list")).toBe(true); + }); +}); diff --git a/web/api/src/gen/api/base/change/proto/change_pb.ts b/web/api/src/gen/api/base/change/proto/change_pb.ts new file mode 100644 index 000000000..a2241fb04 --- /dev/null +++ b/web/api/src/gen/api/base/change/proto/change_pb.ts @@ -0,0 +1,74 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// @generated by protoc-gen-es v2.15.0 with parameter "target=ts,import_extension=js" +// @generated from file api/base/change/proto/change.proto (package uber.base.change, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file api/base/change/proto/change.proto. + */ +export const file_api_base_change_proto_change: GenFile = /*@__PURE__*/ + fileDesc("CiJhcGkvYmFzZS9jaGFuZ2UvcHJvdG8vY2hhbmdlLnByb3RvEhB1YmVyLmJhc2UuY2hhbmdlIhYKBkNoYW5nZRIMCgR1cmlzGAEgAygJQmYKIGNvbS51YmVyLnN1Ym1pdHF1ZXVlLmJhc2UuY2hhbmdlQgtDaGFuZ2VQcm90b1ABWjNnaXRodWIuY29tL3ViZXIvc3VibWl0cXVldWUvYXBpL2Jhc2UvY2hhbmdlL3Byb3RvcGJiBnByb3RvMw"); + +/** + * Change represents a code change identified by URIs from a code change provider + * (e.g. a GitHub Pull Request, a Phabricator Diff, or a git commit). The provider + * is extracted from the URI scheme. + * + * This is the shared wire contract for change identity, reused across SubmitQueue, + * Stovepipe, and other repo-local domains — the proto-level analog of the + * platform/base/change Go entity. Domains import it rather than redefining their own. + * + * @generated from message uber.base.change.Change + */ +export type Change = Message<"uber.base.change.Change"> & { + /** + * URIs identifying the change(s) (RFC 3986 compliant): scheme:///. + * The scheme identifies the change provider, the authority is the provider instance + * the change lives on, and the path contains provider-specific resource identifiers. + * + * Supported by default (other providers can be added): + * GitHub PR: "github://///pull//" + * Phabricator Diff: "phab:///D/" + * git commit: "git://///" + * ( is a fully-qualified, percent-encoded git ref) + * + * The commit SHA must be the full 40-character lowercase hex SHA; abbreviated + * SHAs are rejected because downstream staleness checks compare by strict equality. + * + * One URI is one unit of change -- a single pull request, revision, or + * commit -- and it is the granularity everything downstream operates at. A + * list is therefore an ordered set of distinct changes (a stack), not one + * change described several ways: they are applied in the order given, each + * on top of the last, and each yields its own result. An integration + * strategy chosen for a change applies to every URI in the list, the same + * way to each. + * + * @generated from field: repeated string uris = 1; + */ + uris: string[]; +}; + +/** + * Describes the message uber.base.change.Change. + * Use `create(ChangeSchema)` to create a new message. + */ +export const ChangeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_base_change_proto_change, 0); + diff --git a/web/api/src/gen/api/base/mergestrategy/proto/mergestrategy_pb.ts b/web/api/src/gen/api/base/mergestrategy/proto/mergestrategy_pb.ts new file mode 100644 index 000000000..84c45fc23 --- /dev/null +++ b/web/api/src/gen/api/base/mergestrategy/proto/mergestrategy_pb.ts @@ -0,0 +1,95 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// @generated by protoc-gen-es v2.15.0 with parameter "target=ts,import_extension=js" +// @generated from file api/base/mergestrategy/proto/mergestrategy.proto (package uber.base.mergestrategy, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, fileDesc } from "@bufbuild/protobuf/codegenv2"; + +/** + * Describes the file api/base/mergestrategy/proto/mergestrategy.proto. + */ +export const file_api_base_mergestrategy_proto_mergestrategy: GenFile = /*@__PURE__*/ + fileDesc("CjBhcGkvYmFzZS9tZXJnZXN0cmF0ZWd5L3Byb3RvL21lcmdlc3RyYXRlZ3kucHJvdG8SF3ViZXIuYmFzZS5tZXJnZXN0cmF0ZWd5Kk4KCFN0cmF0ZWd5EgsKB0RFRkFVTFQQABIKCgZSRUJBU0UQARIRCg1TUVVBU0hfUkVCQVNFEAISCQoFTUVSR0UQAxILCgdQUk9NT1RFEARCewonY29tLnViZXIuc3VibWl0cXVldWUuYmFzZS5tZXJnZXN0cmF0ZWd5QhJNZXJnZVN0cmF0ZWd5UHJvdG9QAVo6Z2l0aHViLmNvbS91YmVyL3N1Ym1pdHF1ZXVlL2FwaS9iYXNlL21lcmdlc3RyYXRlZ3kvcHJvdG9wYmIGcHJvdG8z"); + +/** + * Strategy defines the possible source control integration methods — how a change + * is integrated into the target branch. + * + * This is the shared wire contract for merge strategy, reused across SubmitQueue + * and other repo-local domains — the proto-level analog of the + * platform/base/mergestrategy Go entity. Domains import it rather than redefining their own. + * + * A strategy is chosen once for a change and applies to every URI that change + * carries, the same way to each. Since one URI is one unit of change (see + * uber.base.change.Change), the values below are defined per URI: a change + * carrying three URIs is integrated as three separate applications of the same + * strategy, in order, not as one combined application. + * + * @generated from enum uber.base.mergestrategy.Strategy + */ +export enum Strategy { + /** + * Default strategy (let the server decide based on configuration). + * + * @generated from enum value: DEFAULT = 0; + */ + DEFAULT = 0, + + /** + * Rebase the commits the change introduces onto the target branch before landing. + * + * @generated from enum value: REBASE = 1; + */ + REBASE = 1, + + /** + * Same as REBASE, then squash into a single commit. The squash unit is the + * individual change: a change of ten commits becomes one commit, and a + * stack of three URIs becomes three commits rather than one, so the + * boundary between the stacked changes survives. + * + * @generated from enum value: SQUASH_REBASE = 2; + */ + SQUASH_REBASE = 2, + + /** + * Merge into the target branch by creating a separate merge commit, preserving commit history along with hashes. + * + * @generated from enum value: MERGE = 3; + */ + MERGE = 3, + + /** + * Integrate the exact revision as-is, with no content transform — advance the target branch to an already-existing + * commit rather than producing new revisions. The implementer maps it to its backend: git fast-forward, Mercurial + * bookmark advance, Subversion/Perforce copy. Used to promote an already-landed/verified commit onto another branch. + * + * The one strategy that constrains the list rather than repeating over it: + * advancing a ref to an exact revision admits a single URI, because a + * second one could not also be the revision the target ends at. + * + * @generated from enum value: PROMOTE = 4; + */ + PROMOTE = 4, +} + +/** + * Describes the enum uber.base.mergestrategy.Strategy. + */ +export const StrategySchema: GenEnum = /*@__PURE__*/ + enumDesc(file_api_base_mergestrategy_proto_mergestrategy, 0); + diff --git a/web/api/src/gen/api/submitqueue/gateway/proto/gateway_pb.ts b/web/api/src/gen/api/submitqueue/gateway/proto/gateway_pb.ts new file mode 100644 index 000000000..f687bf6a9 --- /dev/null +++ b/web/api/src/gen/api/submitqueue/gateway/proto/gateway_pb.ts @@ -0,0 +1,889 @@ +// Copyright (c) 2025 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// @generated by protoc-gen-es v2.15.0 with parameter "target=ts,import_extension=js" +// @generated from file api/submitqueue/gateway/proto/gateway.proto (package uber.submitqueue.gateway, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Change } from "../../../base/change/proto/change_pb.js"; +import { file_api_base_change_proto_change } from "../../../base/change/proto/change_pb.js"; +import type { Strategy } from "../../../base/mergestrategy/proto/mergestrategy_pb.js"; +import { file_api_base_mergestrategy_proto_mergestrategy } from "../../../base/mergestrategy/proto/mergestrategy_pb.js"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file api/submitqueue/gateway/proto/gateway.proto. + */ +export const file_api_submitqueue_gateway_proto_gateway: GenFile = /*@__PURE__*/ + fileDesc("CithcGkvc3VibWl0cXVldWUvZ2F0ZXdheS9wcm90by9nYXRld2F5LnByb3RvEhh1YmVyLnN1Ym1pdHF1ZXVlLmdhdGV3YXkiHgoLUGluZ1JlcXVlc3QSDwoHbWVzc2FnZRgBIAEoCSJaCgxQaW5nUmVzcG9uc2USDwoHbWVzc2FnZRgBIAEoCRIUCgxzZXJ2aWNlX25hbWUYAiABKAkSEQoJdGltZXN0YW1wGAMgASgDEhAKCGhvc3RuYW1lGAQgASgJInsKC0xhbmRSZXF1ZXN0Eg0KBXF1ZXVlGAEgASgJEigKBmNoYW5nZRgCIAEoCzIYLnViZXIuYmFzZS5jaGFuZ2UuQ2hhbmdlEjMKCHN0cmF0ZWd5GAQgASgOMiEudWJlci5iYXNlLm1lcmdlc3RyYXRlZ3kuU3RyYXRlZ3kiHAoMTGFuZFJlc3BvbnNlEgwKBHNxaWQYASABKAkiPAoNQ2FuY2VsUmVxdWVzdBIMCgRzcWlkGAEgASgJEg4KBnJlYXNvbhgCIAEoCRINCgVxdWV1ZRgDIAEoCSIQCg5DYW5jZWxSZXNwb25zZSL5AQoOUmVxdWVzdFN1bW1hcnkSDAoEc3FpZBgBIAEoCRINCgVxdWV1ZRgCIAEoCRITCgtjaGFuZ2VfdXJpcxgDIAMoCRIWCg5yZWNlaXZlZF9hdF9tcxgEIAEoAxIOCgZzdGF0dXMYBSABKAkSEgoKbGFzdF9lcnJvchgGIAEoCRJICghtZXRhZGF0YRgHIAMoCzI2LnViZXIuc3VibWl0cXVldWUuZ2F0ZXdheS5SZXF1ZXN0U3VtbWFyeS5NZXRhZGF0YUVudHJ5Gi8KDU1ldGFkYXRhRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASI7ChxHZXRSZXF1ZXN0U3VtbWFyeUJ5SURSZXF1ZXN0EgwKBHNxaWQYASABKAkSDQoFcXVldWUYAiABKAkiWgodR2V0UmVxdWVzdFN1bW1hcnlCeUlEUmVzcG9uc2USOQoHcmVxdWVzdBgBIAEoCzIoLnViZXIuc3VibWl0cXVldWUuZ2F0ZXdheS5SZXF1ZXN0U3VtbWFyeSJICiNHZXRSZXF1ZXN0U3VtbWFyeUJ5Q2hhbmdlVVJJUmVxdWVzdBISCgpjaGFuZ2VfdXJpGAEgASgJEg0KBXF1ZXVlGAIgASgJImIKJEdldFJlcXVlc3RTdW1tYXJ5QnlDaGFuZ2VVUklSZXNwb25zZRI6CghyZXF1ZXN0cxgBIAMoCzIoLnViZXIuc3VibWl0cXVldWUuZ2F0ZXdheS5SZXF1ZXN0U3VtbWFyeSKAAQoLTGlzdFJlcXVlc3QSDQoFcXVldWUYASABKAkSHwoXcmVjZWl2ZWRfYXRfb3JfYWZ0ZXJfbXMYAiABKAMSGgoScmVjZWl2ZWRfYmVmb3JlX21zGAMgASgDEhEKCXBhZ2Vfc2l6ZRgEIAEoBRISCgpwYWdlX3Rva2VuGAUgASgJImMKDExpc3RSZXNwb25zZRI6CghyZXF1ZXN0cxgBIAMoCzIoLnViZXIuc3VibWl0cXVldWUuZ2F0ZXdheS5SZXF1ZXN0U3VtbWFyeRIXCg9uZXh0X3BhZ2VfdG9rZW4YAiABKAkiFQoFUXVldWUSDAoEbmFtZRgBIAEoCSITChFMaXN0UXVldWVzUmVxdWVzdCJFChJMaXN0UXVldWVzUmVzcG9uc2USLwoGcXVldWVzGAEgAygLMh8udWJlci5zdWJtaXRxdWV1ZS5nYXRld2F5LlF1ZXVlIjsKHEdldFJlcXVlc3RIaXN0b3J5QnlJRFJlcXVlc3QSDAoEc3FpZBgBIAEoCRINCgVxdWV1ZRgCIAEoCSLeAQoMSGlzdG9yeUV2ZW50EhQKDHRpbWVzdGFtcF9tcxgBIAEoAxIOCgZzdGF0dXMYAiABKAkSEgoKbGFzdF9lcnJvchgDIAEoCRJGCghtZXRhZGF0YRgEIAMoCzI0LnViZXIuc3VibWl0cXVldWUuZ2F0ZXdheS5IaXN0b3J5RXZlbnQuTWV0YWRhdGFFbnRyeRIMCgR0eXBlGAUgASgJEg0KBWV2ZW50GAYgASgJGi8KDU1ldGFkYXRhRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4ASJXCh1HZXRSZXF1ZXN0SGlzdG9yeUJ5SURSZXNwb25zZRI2CgZldmVudHMYASADKAsyJi51YmVyLnN1Ym1pdHF1ZXVlLmdhdGV3YXkuSGlzdG9yeUV2ZW50IkgKI0dldFJlcXVlc3RIaXN0b3J5QnlDaGFuZ2VVUklSZXF1ZXN0EhIKCmNoYW5nZV91cmkYASABKAkSDQoFcXVldWUYAiABKAkiVgoOUmVxdWVzdEhpc3RvcnkSDAoEc3FpZBgBIAEoCRI2CgZldmVudHMYAiADKAsyJi51YmVyLnN1Ym1pdHF1ZXVlLmdhdGV3YXkuSGlzdG9yeUV2ZW50ImMKJEdldFJlcXVlc3RIaXN0b3J5QnlDaGFuZ2VVUklSZXNwb25zZRI7CgloaXN0b3JpZXMYASADKAsyKC51YmVyLnN1Ym1pdHF1ZXVlLmdhdGV3YXkuUmVxdWVzdEhpc3RvcnkiGAoFRXJyb3ISDwoHbWVzc2FnZRgBIAEoCSJXChZVbnJlY29nbml6ZWRRdWV1ZUVycm9yEi4KBWVycm9yGAEgASgLMh8udWJlci5zdWJtaXRxdWV1ZS5nYXRld2F5LkVycm9yEg0KBXF1ZXVlGAIgASgJImgKFFJlcXVlc3ROb3RGb3VuZEVycm9yEi4KBWVycm9yGAEgASgLMh8udWJlci5zdWJtaXRxdWV1ZS5nYXRld2F5LkVycm9yEgwKBHNxaWQYAiABKAkSEgoKY2hhbmdlX3VyaRgDIAEoCTLHCAoSU3VibWl0UXVldWVHYXRld2F5ElcKBFBpbmcSJS51YmVyLnN1Ym1pdHF1ZXVlLmdhdGV3YXkuUGluZ1JlcXVlc3QaJi51YmVyLnN1Ym1pdHF1ZXVlLmdhdGV3YXkuUGluZ1Jlc3BvbnNlIgASaQoKTGlzdFF1ZXVlcxIrLnViZXIuc3VibWl0cXVldWUuZ2F0ZXdheS5MaXN0UXVldWVzUmVxdWVzdBosLnViZXIuc3VibWl0cXVldWUuZ2F0ZXdheS5MaXN0UXVldWVzUmVzcG9uc2UiABJXCgRMYW5kEiUudWJlci5zdWJtaXRxdWV1ZS5nYXRld2F5LkxhbmRSZXF1ZXN0GiYudWJlci5zdWJtaXRxdWV1ZS5nYXRld2F5LkxhbmRSZXNwb25zZSIAEl0KBkNhbmNlbBInLnViZXIuc3VibWl0cXVldWUuZ2F0ZXdheS5DYW5jZWxSZXF1ZXN0GigudWJlci5zdWJtaXRxdWV1ZS5nYXRld2F5LkNhbmNlbFJlc3BvbnNlIgASigEKFUdldFJlcXVlc3RTdW1tYXJ5QnlJRBI2LnViZXIuc3VibWl0cXVldWUuZ2F0ZXdheS5HZXRSZXF1ZXN0U3VtbWFyeUJ5SURSZXF1ZXN0GjcudWJlci5zdWJtaXRxdWV1ZS5nYXRld2F5LkdldFJlcXVlc3RTdW1tYXJ5QnlJRFJlc3BvbnNlIgASnwEKHEdldFJlcXVlc3RTdW1tYXJ5QnlDaGFuZ2VVUkkSPS51YmVyLnN1Ym1pdHF1ZXVlLmdhdGV3YXkuR2V0UmVxdWVzdFN1bW1hcnlCeUNoYW5nZVVSSVJlcXVlc3QaPi51YmVyLnN1Ym1pdHF1ZXVlLmdhdGV3YXkuR2V0UmVxdWVzdFN1bW1hcnlCeUNoYW5nZVVSSVJlc3BvbnNlIgASVwoETGlzdBIlLnViZXIuc3VibWl0cXVldWUuZ2F0ZXdheS5MaXN0UmVxdWVzdBomLnViZXIuc3VibWl0cXVldWUuZ2F0ZXdheS5MaXN0UmVzcG9uc2UiABKKAQoVR2V0UmVxdWVzdEhpc3RvcnlCeUlEEjYudWJlci5zdWJtaXRxdWV1ZS5nYXRld2F5LkdldFJlcXVlc3RIaXN0b3J5QnlJRFJlcXVlc3QaNy51YmVyLnN1Ym1pdHF1ZXVlLmdhdGV3YXkuR2V0UmVxdWVzdEhpc3RvcnlCeUlEUmVzcG9uc2UiABKfAQocR2V0UmVxdWVzdEhpc3RvcnlCeUNoYW5nZVVSSRI9LnViZXIuc3VibWl0cXVldWUuZ2F0ZXdheS5HZXRSZXF1ZXN0SGlzdG9yeUJ5Q2hhbmdlVVJJUmVxdWVzdBo+LnViZXIuc3VibWl0cXVldWUuZ2F0ZXdheS5HZXRSZXF1ZXN0SGlzdG9yeUJ5Q2hhbmdlVVJJUmVzcG9uc2UiAEJrChxjb20udWJlci5zdWJtaXRxdWV1ZS5nYXRld2F5QgxHYXRld2F5UHJvdG9QAVo7Z2l0aHViLmNvbS91YmVyL3N1Ym1pdHF1ZXVlL2FwaS9zdWJtaXRxdWV1ZS9nYXRld2F5L3Byb3RvcGJiBnByb3RvMw", [file_api_base_change_proto_change, file_api_base_mergestrategy_proto_mergestrategy]); + +/** + * PingRequest is the request for the Ping method + * + * @generated from message uber.submitqueue.gateway.PingRequest + */ +export type PingRequest = Message<"uber.submitqueue.gateway.PingRequest"> & { + /** + * Optional message to include in the ping + * + * @generated from field: string message = 1; + */ + message: string; +}; + +/** + * Describes the message uber.submitqueue.gateway.PingRequest. + * Use `create(PingRequestSchema)` to create a new message. + */ +export const PingRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 0); + +/** + * PingResponse is the response for the Ping method + * + * @generated from message uber.submitqueue.gateway.PingResponse + */ +export type PingResponse = Message<"uber.submitqueue.gateway.PingResponse"> & { + /** + * The response message + * + * @generated from field: string message = 1; + */ + message: string; + + /** + * The service name that handled the request + * + * @generated from field: string service_name = 2; + */ + serviceName: string; + + /** + * Timestamp of when the ping was received + * + * @generated from field: int64 timestamp = 3; + */ + timestamp: bigint; + + /** + * Hostname of the server that handled the request + * + * @generated from field: string hostname = 4; + */ + hostname: string; +}; + +/** + * Describes the message uber.submitqueue.gateway.PingResponse. + * Use `create(PingResponseSchema)` to create a new message. + */ +export const PingResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 1); + +/** + * LandRequest defines a request to land a set of code changes on the source control repository's target branch. + * + * SubmitQueue guarantees changes are landed in order with no other changes in between. + * SubmitQueue does not guarantee each change is individually valid, but produces a validity marker on such changes. + * The user is responsible for including all changes in a stack in order, starting from the earliest change. + * + * @generated from message uber.submitqueue.gateway.LandRequest + */ +export type LandRequest = Message<"uber.submitqueue.gateway.LandRequest"> & { + /** + * Name of the queue processing the land request. The queue should be defined in the configuration, otherwise an error is returned. + * + * @generated from field: string queue = 1; + */ + queue: string; + + /** + * Change (such as a pull request) to land into the target branch. Target branch is defined by the queue configuration. + * + * @generated from field: uber.base.change.Change change = 2; + */ + change?: Change | undefined; + + /** + * Source control integration strategy to use for this land operation. If not specified, the default queue strategy is used. + * It applies to every URI the change carries, the same way to each — a land + * request cannot pick a different strategy per URI. + * + * @generated from field: uber.base.mergestrategy.Strategy strategy = 4; + */ + strategy: Strategy; +}; + +/** + * Describes the message uber.submitqueue.gateway.LandRequest. + * Use `create(LandRequestSchema)` to create a new message. + */ +export const LandRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 2); + +/** + * LandResponse defines the response to a land request. + * + * @generated from message uber.submitqueue.gateway.LandResponse + */ +export type LandResponse = Message<"uber.submitqueue.gateway.LandResponse"> & { + /** + * Canonical decimal identifier for the land request, unique within its queue. + * + * @generated from field: string sqid = 1; + */ + sqid: string; +}; + +/** + * Describes the message uber.submitqueue.gateway.LandResponse. + * Use `create(LandResponseSchema)` to create a new message. + */ +export const LandResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 3); + +/** + * CancelRequest defines a request to cancel an in-flight land request. If the request is part of a batch, the entire batch is cancelled. + * + * @generated from message uber.submitqueue.gateway.CancelRequest + */ +export type CancelRequest = Message<"uber.submitqueue.gateway.CancelRequest"> & { + /** + * Canonical decimal identifier of the land request to cancel, returned by a prior Land call. + * + * @generated from field: string sqid = 1; + */ + sqid: string; + + /** + * Optional human-readable reason for the cancellation. Recorded for observability. + * + * @generated from field: string reason = 2; + */ + reason: string; + + /** + * Name of the queue processing the request. Required. A sqid is only resolvable within its own queue, + * so naming a queue the request does not belong to is reported as not found. + * + * @generated from field: string queue = 3; + */ + queue: string; +}; + +/** + * Describes the message uber.submitqueue.gateway.CancelRequest. + * Use `create(CancelRequestSchema)` to create a new message. + */ +export const CancelRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 4); + +/** + * CancelResponse defines the response to a cancel request. Empty on success. + * + * A successful response indicates only that the cancellation intent was accepted and enqueued — it does NOT confirm + * that the request was actually cancelled. The terminal outcome may still be "landed" or "error" if a race was lost. + * Errors are returned as standard status codes (e.g. InvalidArgument for an empty sqid). + * + * @generated from message uber.submitqueue.gateway.CancelResponse + */ +export type CancelResponse = Message<"uber.submitqueue.gateway.CancelResponse"> & { +}; + +/** + * Describes the message uber.submitqueue.gateway.CancelResponse. + * Use `create(CancelResponseSchema)` to create a new message. + */ +export const CancelResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 5); + +/** + * RequestSummary is the gateway-owned materialized current view of one received request. + * + * @generated from message uber.submitqueue.gateway.RequestSummary + */ +export type RequestSummary = Message<"uber.submitqueue.gateway.RequestSummary"> & { + /** + * Queue-scoped decimal request identifier. This is request_id in internal storage. + * + * @generated from field: string sqid = 1; + */ + sqid: string; + + /** + * Queue supplied when the request was received. + * + * @generated from field: string queue = 2; + */ + queue: string; + + /** + * Change URIs supplied when the request was received, in caller order. + * + * @generated from field: repeated string change_uris = 3; + */ + changeUris: string[]; + + /** + * Receipt time in Unix milliseconds. + * + * @generated from field: int64 received_at_ms = 4; + */ + receivedAtMs: bigint; + + /** + * Current customer-friendly status of the request. + * + * @generated from field: string status = 5; + */ + status: string; + + /** + * Last error associated with the current status. Empty when absent. + * + * @generated from field: string last_error = 6; + */ + lastError: string; + + /** + * Display and debugging metadata associated with the current status. + * + * @generated from field: map metadata = 7; + */ + metadata: { [key: string]: string }; +}; + +/** + * Describes the message uber.submitqueue.gateway.RequestSummary. + * Use `create(RequestSummarySchema)` to create a new message. + */ +export const RequestSummarySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 6); + +/** + * GetRequestSummaryByIDRequest selects one request by the sqid returned from Land. + * + * @generated from message uber.submitqueue.gateway.GetRequestSummaryByIDRequest + */ +export type GetRequestSummaryByIDRequest = Message<"uber.submitqueue.gateway.GetRequestSummaryByIDRequest"> & { + /** + * Canonical decimal identifier for the request, resolved within queue. + * + * @generated from field: string sqid = 1; + */ + sqid: string; + + /** + * Name of the queue processing the request. Required. A sqid is only resolvable within its own queue, + * so naming a queue the request does not belong to is reported as not found. + * + * @generated from field: string queue = 2; + */ + queue: string; +}; + +/** + * Describes the message uber.submitqueue.gateway.GetRequestSummaryByIDRequest. + * Use `create(GetRequestSummaryByIDRequestSchema)` to create a new message. + */ +export const GetRequestSummaryByIDRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 7); + +/** + * GetRequestSummaryByIDResponse contains the current materialized request view. + * + * @generated from message uber.submitqueue.gateway.GetRequestSummaryByIDResponse + */ +export type GetRequestSummaryByIDResponse = Message<"uber.submitqueue.gateway.GetRequestSummaryByIDResponse"> & { + /** + * Matching request. + * + * @generated from field: uber.submitqueue.gateway.RequestSummary request = 1; + */ + request?: RequestSummary | undefined; +}; + +/** + * Describes the message uber.submitqueue.gateway.GetRequestSummaryByIDResponse. + * Use `create(GetRequestSummaryByIDResponseSchema)` to create a new message. + */ +export const GetRequestSummaryByIDResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 8); + +/** + * GetRequestSummaryByChangeURIRequest selects requests by an exact pinned change URI. + * + * @generated from message uber.submitqueue.gateway.GetRequestSummaryByChangeURIRequest + */ +export type GetRequestSummaryByChangeURIRequest = Message<"uber.submitqueue.gateway.GetRequestSummaryByChangeURIRequest"> & { + /** + * Exact change URI supplied in a Land request. + * + * @generated from field: string change_uri = 1; + */ + changeUri: string; + + /** + * Name of the queue to search. Required. Results are scoped to this queue: a change URI landed + * into several queues is looked up one queue at a time. + * + * @generated from field: string queue = 2; + */ + queue: string; +}; + +/** + * Describes the message uber.submitqueue.gateway.GetRequestSummaryByChangeURIRequest. + * Use `create(GetRequestSummaryByChangeURIRequestSchema)` to create a new message. + */ +export const GetRequestSummaryByChangeURIRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 9); + +/** + * GetRequestSummaryByChangeURIResponse contains matching requests newest first. + * + * @generated from message uber.submitqueue.gateway.GetRequestSummaryByChangeURIResponse + */ +export type GetRequestSummaryByChangeURIResponse = Message<"uber.submitqueue.gateway.GetRequestSummaryByChangeURIResponse"> & { + /** + * Matching requests ordered by receipt time descending, then sqid descending. + * + * @generated from field: repeated uber.submitqueue.gateway.RequestSummary requests = 1; + */ + requests: RequestSummary[]; +}; + +/** + * Describes the message uber.submitqueue.gateway.GetRequestSummaryByChangeURIResponse. + * Use `create(GetRequestSummaryByChangeURIResponseSchema)` to create a new message. + */ +export const GetRequestSummaryByChangeURIResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 10); + +/** + * ListRequest defines one bounded queue receipt-history query. + * + * @generated from message uber.submitqueue.gateway.ListRequest + */ +export type ListRequest = Message<"uber.submitqueue.gateway.ListRequest"> & { + /** + * Exact queue to query. + * + * @generated from field: string queue = 1; + */ + queue: string; + + /** + * Inclusive lower receipt-time bound in Unix milliseconds. + * + * @generated from field: int64 received_at_or_after_ms = 2; + */ + receivedAtOrAfterMs: bigint; + + /** + * Exclusive upper receipt-time bound in Unix milliseconds. + * + * @generated from field: int64 received_before_ms = 3; + */ + receivedBeforeMs: bigint; + + /** + * Maximum results to return. Zero selects the server default; values above the server maximum are invalid. + * + * @generated from field: int32 page_size = 4; + */ + pageSize: number; + + /** + * Opaque continuation token from a previous response. Empty selects the first page. + * + * @generated from field: string page_token = 5; + */ + pageToken: string; +}; + +/** + * Describes the message uber.submitqueue.gateway.ListRequest. + * Use `create(ListRequestSchema)` to create a new message. + */ +export const ListRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 11); + +/** + * ListResponse defines one page of queue receipt history. + * + * @generated from message uber.submitqueue.gateway.ListResponse + */ +export type ListResponse = Message<"uber.submitqueue.gateway.ListResponse"> & { + /** + * Requests ordered by receipt time descending, then sqid descending. + * + * @generated from field: repeated uber.submitqueue.gateway.RequestSummary requests = 1; + */ + requests: RequestSummary[]; + + /** + * Opaque continuation token. Empty when this is the last page. + * + * @generated from field: string next_page_token = 2; + */ + nextPageToken: string; +}; + +/** + * Describes the message uber.submitqueue.gateway.ListResponse. + * Use `create(ListResponseSchema)` to create a new message. + */ +export const ListResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 12); + +/** + * Queue identifies a configured submit queue. + * + * @generated from message uber.submitqueue.gateway.Queue + */ +export type Queue = Message<"uber.submitqueue.gateway.Queue"> & { + /** + * Unique queue name accepted by the gateway. + * + * @generated from field: string name = 1; + */ + name: string; +}; + +/** + * Describes the message uber.submitqueue.gateway.Queue. + * Use `create(QueueSchema)` to create a new message. + */ +export const QueueSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 13); + +/** + * ListQueuesRequest selects all configured submit queues. + * + * @generated from message uber.submitqueue.gateway.ListQueuesRequest + */ +export type ListQueuesRequest = Message<"uber.submitqueue.gateway.ListQueuesRequest"> & { +}; + +/** + * Describes the message uber.submitqueue.gateway.ListQueuesRequest. + * Use `create(ListQueuesRequestSchema)` to create a new message. + */ +export const ListQueuesRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 14); + +/** + * ListQueuesResponse contains all configured queues, including queues with no requests. + * + * @generated from message uber.submitqueue.gateway.ListQueuesResponse + */ +export type ListQueuesResponse = Message<"uber.submitqueue.gateway.ListQueuesResponse"> & { + /** + * Queues ordered by name ascending. + * + * @generated from field: repeated uber.submitqueue.gateway.Queue queues = 1; + */ + queues: Queue[]; +}; + +/** + * Describes the message uber.submitqueue.gateway.ListQueuesResponse. + * Use `create(ListQueuesResponseSchema)` to create a new message. + */ +export const ListQueuesResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 15); + +/** + * GetRequestHistoryByIDRequest selects one retained request history by sqid. + * + * @generated from message uber.submitqueue.gateway.GetRequestHistoryByIDRequest + */ +export type GetRequestHistoryByIDRequest = Message<"uber.submitqueue.gateway.GetRequestHistoryByIDRequest"> & { + /** + * Canonical decimal identifier for the request, resolved within queue. + * + * @generated from field: string sqid = 1; + */ + sqid: string; + + /** + * Name of the queue processing the request. Required. A sqid is only resolvable within its own queue, + * so naming a queue the request does not belong to is reported as not found. + * + * @generated from field: string queue = 2; + */ + queue: string; +}; + +/** + * Describes the message uber.submitqueue.gateway.GetRequestHistoryByIDRequest. + * Use `create(GetRequestHistoryByIDRequestSchema)` to create a new message. + */ +export const GetRequestHistoryByIDRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 16); + +/** + * HistoryEvent is one retained append-only request-log event. + * + * @generated from message uber.submitqueue.gateway.HistoryEvent + */ +export type HistoryEvent = Message<"uber.submitqueue.gateway.HistoryEvent"> & { + /** + * Time the request-log entry was created, in Unix milliseconds. + * + * @generated from field: int64 timestamp_ms = 1; + */ + timestampMs: bigint; + + /** + * Customer-friendly request status recorded by the event. Set only when type is "status". + * + * @generated from field: string status = 2; + */ + status: string; + + /** + * Error message associated with the event. Empty when absent. + * + * @generated from field: string last_error = 3; + */ + lastError: string; + + /** + * Display and debugging metadata associated with this event. Each lifecycle event carries its own values. + * + * @generated from field: map metadata = 4; + */ + metadata: { [key: string]: string }; + + /** + * What this entry records: "status" when the request reached a position in the pipeline, + * "event" when something happened while it sat at one. Exactly one of status and event is set. + * + * @generated from field: string type = 5; + */ + type: string; + + /** + * Occurrence recorded by the entry, e.g. a build starting or finishing. Set only when type is "event". + * A request records many of these — one per build — and they never change its current status. + * + * @generated from field: string event = 6; + */ + event: string; +}; + +/** + * Describes the message uber.submitqueue.gateway.HistoryEvent. + * Use `create(HistoryEventSchema)` to create a new message. + */ +export const HistoryEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 17); + +/** + * GetRequestHistoryByIDResponse contains all retained events for one request. + * + * @generated from message uber.submitqueue.gateway.GetRequestHistoryByIDResponse + */ +export type GetRequestHistoryByIDResponse = Message<"uber.submitqueue.gateway.GetRequestHistoryByIDResponse"> & { + /** + * Events ordered by timestamp ascending with a stable storage tie-breaker. + * + * @generated from field: repeated uber.submitqueue.gateway.HistoryEvent events = 1; + */ + events: HistoryEvent[]; +}; + +/** + * Describes the message uber.submitqueue.gateway.GetRequestHistoryByIDResponse. + * Use `create(GetRequestHistoryByIDResponseSchema)` to create a new message. + */ +export const GetRequestHistoryByIDResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 18); + +/** + * GetRequestHistoryByChangeURIRequest selects retained histories by an exact pinned change URI. + * + * @generated from message uber.submitqueue.gateway.GetRequestHistoryByChangeURIRequest + */ +export type GetRequestHistoryByChangeURIRequest = Message<"uber.submitqueue.gateway.GetRequestHistoryByChangeURIRequest"> & { + /** + * Exact change URI supplied in a Land request. + * + * @generated from field: string change_uri = 1; + */ + changeUri: string; + + /** + * Name of the queue to search. Required. Results are scoped to this queue: a change URI landed + * into several queues is looked up one queue at a time. + * + * @generated from field: string queue = 2; + */ + queue: string; +}; + +/** + * Describes the message uber.submitqueue.gateway.GetRequestHistoryByChangeURIRequest. + * Use `create(GetRequestHistoryByChangeURIRequestSchema)` to create a new message. + */ +export const GetRequestHistoryByChangeURIRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 19); + +/** + * RequestHistory groups retained events for one request. + * + * @generated from message uber.submitqueue.gateway.RequestHistory + */ +export type RequestHistory = Message<"uber.submitqueue.gateway.RequestHistory"> & { + /** + * Queue-scoped decimal request identifier. + * + * @generated from field: string sqid = 1; + */ + sqid: string; + + /** + * Retained events ordered chronologically. + * + * @generated from field: repeated uber.submitqueue.gateway.HistoryEvent events = 2; + */ + events: HistoryEvent[]; +}; + +/** + * Describes the message uber.submitqueue.gateway.RequestHistory. + * Use `create(RequestHistorySchema)` to create a new message. + */ +export const RequestHistorySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 20); + +/** + * GetRequestHistoryByChangeURIResponse contains retained histories ordered by numeric sqid counter ascending. + * + * @generated from message uber.submitqueue.gateway.GetRequestHistoryByChangeURIResponse + */ +export type GetRequestHistoryByChangeURIResponse = Message<"uber.submitqueue.gateway.GetRequestHistoryByChangeURIResponse"> & { + /** + * Matching request histories. + * + * @generated from field: repeated uber.submitqueue.gateway.RequestHistory histories = 1; + */ + histories: RequestHistory[]; +}; + +/** + * Describes the message uber.submitqueue.gateway.GetRequestHistoryByChangeURIResponse. + * Use `create(GetRequestHistoryByChangeURIResponseSchema)` to create a new message. + */ +export const GetRequestHistoryByChangeURIResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 21); + +/** + * Generic error with metadata. Each custom error type should extend this message. + * + * @generated from message uber.submitqueue.gateway.Error + */ +export type Error = Message<"uber.submitqueue.gateway.Error"> & { + /** + * Free text error message describing the error. + * + * @generated from field: string message = 1; + */ + message: string; +}; + +/** + * Describes the message uber.submitqueue.gateway.Error. + * Use `create(ErrorSchema)` to create a new message. + */ +export const ErrorSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 22); + +/** + * UnrecognizedQueueError is returned when a queue name is not recognized. Typically this indicates a typo in the queue name or server misconfiguration. + * + * @generated from message uber.submitqueue.gateway.UnrecognizedQueueError + */ +export type UnrecognizedQueueError = Message<"uber.submitqueue.gateway.UnrecognizedQueueError"> & { + /** + * Free text error message describing the error. + * + * @generated from field: uber.submitqueue.gateway.Error error = 1; + */ + error?: Error | undefined; + + /** + * Name of the queue that was not recognized. + * + * @generated from field: string queue = 2; + */ + queue: string; +}; + +/** + * Describes the message uber.submitqueue.gateway.UnrecognizedQueueError. + * Use `create(UnrecognizedQueueErrorSchema)` to create a new message. + */ +export const UnrecognizedQueueErrorSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 23); + +/** + * RequestNotFoundError is returned when no request is found for the given sqid. Typically this indicates a typo in the sqid or that the request has not been accepted yet. + * + * @generated from message uber.submitqueue.gateway.RequestNotFoundError + */ +export type RequestNotFoundError = Message<"uber.submitqueue.gateway.RequestNotFoundError"> & { + /** + * Free text error message describing the error. + * + * @generated from field: uber.submitqueue.gateway.Error error = 1; + */ + error?: Error | undefined; + + /** + * The sqid that was not found. + * + * @generated from field: string sqid = 2; + */ + sqid: string; + + /** + * Exact change URI that was not found. Populated only for change-URI lookup. + * + * @generated from field: string change_uri = 3; + */ + changeUri: string; +}; + +/** + * Describes the message uber.submitqueue.gateway.RequestNotFoundError. + * Use `create(RequestNotFoundErrorSchema)` to create a new message. + */ +export const RequestNotFoundErrorSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_api_submitqueue_gateway_proto_gateway, 24); + +/** + * SubmitQueueGateway provides the gateway API + * + * @generated from service uber.submitqueue.gateway.SubmitQueueGateway + */ +export const SubmitQueueGateway: GenService<{ + /** + * Ping returns a response indicating the service is alive + * + * @generated from rpc uber.submitqueue.gateway.SubmitQueueGateway.Ping + */ + ping: { + methodKind: "unary"; + input: typeof PingRequestSchema; + output: typeof PingResponseSchema; + }, + /** + * ListQueues returns all configured submit queues ordered by name ascending. + * + * @generated from rpc uber.submitqueue.gateway.SubmitQueueGateway.ListQueues + */ + listQueues: { + methodKind: "unary"; + input: typeof ListQueuesRequestSchema; + output: typeof ListQueuesResponseSchema; + }, + /** + * Land lands a set of code changes into a target branch, performing the necessary validations across all other changes in the queue. + * The processing is asynchronous and returns a LandResponse immediately. The land request is processed in the background. + * + * @generated from rpc uber.submitqueue.gateway.SubmitQueueGateway.Land + */ + land: { + methodKind: "unary"; + input: typeof LandRequestSchema; + output: typeof LandResponseSchema; + }, + /** + * Cancel cancels an in-flight land request identified by its sqid. If the request has already been included in a batch, + * the entire batch is cancelled. Cancel is idempotent — calling it for an already-terminal request is a no-op. + * + * Cancellation is ASYNCHRONOUS: Cancel returns as soon as the cancellation intent has been accepted; the actual + * state transition is performed in the background by the orchestrator and may not have completed by the time the + * caller receives a response. + * + * Cancellation is NOT GUARANTEED: a request that has already landed, or that races to completion before the cancel + * signal propagates through the pipeline, may still land (or end in an error). Callers must NOT assume that a + * successful Cancel response means the request was cancelled — the actual terminal outcome (cancelled, landed, or + * error) must be checked through the request-summary or request-history APIs. + * + * @generated from rpc uber.submitqueue.gateway.SubmitQueueGateway.Cancel + */ + cancel: { + methodKind: "unary"; + input: typeof CancelRequestSchema; + output: typeof CancelResponseSchema; + }, + /** + * GetRequestSummaryByID returns the current materialized status of one request. + * + * @generated from rpc uber.submitqueue.gateway.SubmitQueueGateway.GetRequestSummaryByID + */ + getRequestSummaryByID: { + methodKind: "unary"; + input: typeof GetRequestSummaryByIDRequestSchema; + output: typeof GetRequestSummaryByIDResponseSchema; + }, + /** + * GetRequestSummaryByChangeURI returns current materialized statuses for an exact pinned change URI. + * + * @generated from rpc uber.submitqueue.gateway.SubmitQueueGateway.GetRequestSummaryByChangeURI + */ + getRequestSummaryByChangeURI: { + methodKind: "unary"; + input: typeof GetRequestSummaryByChangeURIRequestSchema; + output: typeof GetRequestSummaryByChangeURIResponseSchema; + }, + /** + * List returns requests received for one queue during a bounded receipt-time range. + * + * @generated from rpc uber.submitqueue.gateway.SubmitQueueGateway.List + */ + list: { + methodKind: "unary"; + input: typeof ListRequestSchema; + output: typeof ListResponseSchema; + }, + /** + * GetRequestHistoryByID returns every retained request-log event for one request. + * + * @generated from rpc uber.submitqueue.gateway.SubmitQueueGateway.GetRequestHistoryByID + */ + getRequestHistoryByID: { + methodKind: "unary"; + input: typeof GetRequestHistoryByIDRequestSchema; + output: typeof GetRequestHistoryByIDResponseSchema; + }, + /** + * GetRequestHistoryByChangeURI returns retained request histories for an exact pinned change URI. + * + * @generated from rpc uber.submitqueue.gateway.SubmitQueueGateway.GetRequestHistoryByChangeURI + */ + getRequestHistoryByChangeURI: { + methodKind: "unary"; + input: typeof GetRequestHistoryByChangeURIRequestSchema; + output: typeof GetRequestHistoryByChangeURIResponseSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_api_submitqueue_gateway_proto_gateway, 0); + diff --git a/web/api/tsconfig.build.json b/web/api/tsconfig.build.json new file mode 100644 index 000000000..6ec5be4c9 --- /dev/null +++ b/web/api/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "noEmit": false, + "outDir": "dist", + "rootDir": "src/gen", + "sourceMap": true + }, + "exclude": ["src/**/*.test.ts"] +} diff --git a/web/api/tsconfig.json b/web/api/tsconfig.json new file mode 100644 index 000000000..d26062d30 --- /dev/null +++ b/web/api/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 000000000..c5f84eeb2 --- /dev/null +++ b/web/package.json @@ -0,0 +1,36 @@ +{ + "name": "submitqueue-web-workspace", + "private": true, + "type": "module", + "version": "0.0.0", + "packageManager": "pnpm@10.17.1", + "engines": { + "node": ">=22.20.0 <23", + "pnpm": ">=10.17.1 <11" + }, + "scripts": { + "build": "pnpm --recursive --if-present run build", + "check": "pnpm build && pnpm lint && pnpm typecheck && pnpm test", + "lint": "pnpm --recursive --if-present run lint", + "test": "pnpm --recursive --if-present run test", + "typecheck": "tsc --project tsconfig.e2e.json && pnpm --recursive --if-present run typecheck" + }, + "devDependencies": { + "@axe-core/playwright": "4.10.2", + "@bufbuild/buf": "1.73.0", + "@bufbuild/protobuf": "2.15.0", + "@bufbuild/protoc-gen-es": "2.15.0", + "@connectrpc/connect": "2.1.1", + "@connectrpc/connect-node": "2.1.1", + "@opentelemetry/api": "1.9.0", + "@playwright/test": "1.55.1", + "@submitqueue/api": "workspace:*", + "@types/node": "22.18.6", + "next": "16.3.6", + "pino": "10.4.0", + "react": "19.3.0", + "react-dom": "19.3.0", + "typescript": "5.9.3", + "vitest": "4.0.7" + } +} diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 000000000..5444033df --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from "@playwright/test"; + +const baseURL = process.env.SUBMITQUEUE_E2E_WEB_URL; +const gatewayURL = process.env.SUBMITQUEUE_E2E_GATEWAY_URL; +const token = process.env.SUBMITQUEUE_WEB_TOKEN; +const chromiumExecutablePath = + process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; + +if (!baseURL || !gatewayURL || !token || !chromiumExecutablePath) { + throw new Error( + "SUBMITQUEUE_E2E_WEB_URL, SUBMITQUEUE_E2E_GATEWAY_URL, SUBMITQUEUE_WEB_TOKEN, and PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH are required", + ); +} + +export default defineConfig({ + outputDir: process.env.PLAYWRIGHT_OUTPUT_DIR ?? "test-results", + testDir: "./test/e2e", + // No per-test limit: the Bazel test timeout bounds the run. + timeout: 0, + fullyParallel: false, + workers: 1, + reporter: "line", + use: { + baseURL, + httpCredentials: { + username: "test", + password: token, + }, + launchOptions: { + executablePath: chromiumExecutablePath, + }, + trace: "retain-on-failure", + }, +}); diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 000000000..761d8a10f --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,5981 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@axe-core/playwright': + specifier: 4.10.2 + version: 4.10.2(playwright-core@1.55.1) + '@bufbuild/buf': + specifier: 1.73.0 + version: 1.73.0 + '@bufbuild/protobuf': + specifier: 2.15.0 + version: 2.15.0 + '@bufbuild/protoc-gen-es': + specifier: 2.15.0 + version: 2.15.0(@bufbuild/protobuf@2.15.0) + '@connectrpc/connect': + specifier: 2.1.1 + version: 2.1.1(@bufbuild/protobuf@2.15.0) + '@connectrpc/connect-node': + specifier: 2.1.1 + version: 2.1.1(@bufbuild/protobuf@2.15.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.15.0)) + '@opentelemetry/api': + specifier: 1.9.0 + version: 1.9.0 + '@playwright/test': + specifier: 1.55.1 + version: 1.55.1 + '@submitqueue/api': + specifier: workspace:* + version: link:api + '@types/node': + specifier: 22.18.6 + version: 22.18.6 + next: + specifier: 16.3.6 + version: 16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.1)(@types/node@22.18.6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + pino: + specifier: 10.4.0 + version: 10.4.0 + react: + specifier: 19.3.0 + version: 19.3.0 + react-dom: + specifier: 19.3.0 + version: 19.3.0(react@19.3.0) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 4.0.7 + version: 4.0.7(@types/node@22.18.6)(jsdom@27.4.0) + + api: + dependencies: + '@bufbuild/protobuf': + specifier: 2.15.0 + version: 2.15.0 + + service/submitqueue: + dependencies: + '@bufbuild/protobuf': + specifier: 2.15.0 + version: 2.15.0 + '@connectrpc/connect': + specifier: ^2.1.1 + version: 2.1.1(@bufbuild/protobuf@2.15.0) + '@connectrpc/connect-node': + specifier: ^2.1.1 + version: 2.1.1(@bufbuild/protobuf@2.15.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.15.0)) + '@opentelemetry/api': + specifier: 1.9.0 + version: 1.9.0 + '@opentelemetry/exporter-prometheus': + specifier: ^0.223.0 + version: 0.223.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': + specifier: ^2.12.0 + version: 2.12.0(@opentelemetry/api@1.9.0) + '@submitqueue/api': + specifier: 0.1.0 + version: link:../../api + '@submitqueue/web-submitqueue': + specifier: 0.0.0 + version: link:../../submitqueue + next: + specifier: '>=16.3.0 <17' + version: 16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.1)(@types/node@22.18.6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + pino: + specifier: ^10.4.0 + version: 10.4.0 + react: + specifier: ^19.2.0 + version: 19.3.0 + react-dom: + specifier: ^19.2.0 + version: 19.3.0(react@19.3.0) + server-only: + specifier: ^0.0.1 + version: 0.0.1 + devDependencies: + '@types/node': + specifier: 22.18.6 + version: 22.18.6 + '@types/react': + specifier: ^19.1.16 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.1.9 + version: 19.3.0(@types/react@19.3.0) + eslint: + specifier: ^9.36.0 + version: 9.39.5 + eslint-config-next: + specifier: '>=16.3.0 <17' + version: 16.3.6(@typescript-eslint/parser@8.71.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3) + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 4.0.7 + version: 4.0.7(@types/node@22.18.6)(jsdom@27.4.0) + + submitqueue: + devDependencies: + '@connectrpc/connect': + specifier: ^2.1.1 + version: 2.2.0(@bufbuild/protobuf@2.15.0) + '@opentelemetry/api': + specifier: ^1.9.0 + version: 1.9.1 + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.10.0(@testing-library/dom@10.4.2) + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@types/node': + specifier: 22.18.6 + version: 22.18.6 + '@types/react': + specifier: ^19.1.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.1.0 + version: 19.3.0(@types/react@19.3.0) + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + pino: + specifier: ^10.4.0 + version: 10.4.0 + react: + specifier: ^19.1.0 + version: 19.3.0 + react-dom: + specifier: ^19.1.0 + version: 19.3.0(react@19.3.0) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: 4.0.7 + version: 4.0.7(@types/node@22.18.6)(jsdom@26.1.0) + + test/package/react18: + dependencies: + jsdom: + specifier: ^26.1.0 + version: 26.1.0 + react: + specifier: 18.3.1 + version: 18.3.1 + react-dom: + specifier: 18.3.1 + version: 18.3.1(react@18.3.1) + +packages: + + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@asamuzakjp/css-color@4.1.2': + resolution: {integrity: sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@axe-core/playwright@4.10.2': + resolution: {integrity: sha512-6/b5BJjG6hDaRNtgzLIfKr5DfwyiLHO4+ByTLB0cJgWSM8Ll7KqtdblIS6bEkwSF642/Ex91vNqIl3GLXGlceg==} + peerDependencies: + playwright-core: '>= 1.0.0' + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bufbuild/buf-darwin-arm64@1.73.0': + resolution: {integrity: sha512-cDd3y376ecClhyytNLlY3AtsZqsqXvEkQjMvrahrv/xUWWhEIX3efD8y0uufXDe8Yuk3sEmj7J1Xlx73Y46SkA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@bufbuild/buf-darwin-x64@1.73.0': + resolution: {integrity: sha512-VTBTCwaqT7otsMGXlk3MRu6x5hp+YH9PaLKcyWAa8wEt0NnMzbXdkE8GiLgI8Qkl7S3+pC9YSTCE/I+O87Fvrw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@bufbuild/buf-linux-aarch64@1.73.0': + resolution: {integrity: sha512-HjoQ3wbLiUv5N5XLM3EAR2FY51Yn6t5MEXLPnS/Hx0KDkK/5eyIBZZ6toHBLjczAr1RTA1LagF38+lglZI+kvA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@bufbuild/buf-linux-armv7@1.73.0': + resolution: {integrity: sha512-NsZuVg8hXfGcR96Is14ArKxZg6pdJb6kb63bx2g7q8Cjo+gRqZMBFKGvF/fnQMxTrRQAm6hzeF4a2vV4W3lszg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@bufbuild/buf-linux-x64@1.73.0': + resolution: {integrity: sha512-dMe6lBQrlHTU7DzqzaR7Udky7R78EDzzxtYZQdjkZun9jKdjGR4fLbPWEPyY4rLGVyk6UUnrtkf5qoIK2SC6qw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@bufbuild/buf-win32-arm64@1.73.0': + resolution: {integrity: sha512-UmubCflc8zJ48bVW0WeGeVTkNuumJP//wCWKUmeTXT1iNlEqkpaXy6y7o8YJKHGWLfX0/1UV3oay9ti0EyojMw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@bufbuild/buf-win32-x64@1.73.0': + resolution: {integrity: sha512-/Buo10rXzqNLnFDh3BpzGdcrx6OalcU8o8gUyObCFZA5wKcUvBVNDhhpMmoB6YeiOPpPn47jHgW4bSSlypaaOQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@bufbuild/buf@1.73.0': + resolution: {integrity: sha512-W3whb4P1y3Hs9bqwCu9++W1v1ofHZ3dBUCe6JSZq0Ff5OK5UKKZuMN4iunPbgdyXY4+4ujEYf2ESrN/6MbcFSQ==} + engines: {node: '>=12'} + hasBin: true + + '@bufbuild/protobuf@2.15.0': + resolution: {integrity: sha512-DAheWUkVr/SJTWCc+lg9dhY0eN4SaWlf4+bG1KzHeXbnqt0AfB/NX0Z+VunGlM1ki1B4zVvye27MpKh/svySUA==} + + '@bufbuild/protoc-gen-es@2.15.0': + resolution: {integrity: sha512-FRhNTAZEXW6arWx2JxgtpuazhmewYctXugu5SfXGpXpLLCInDT1005fSKDzEM/70Es2oIn07PM4RvpwvDvR/fg==} + engines: {node: '>=22'} + hasBin: true + peerDependencies: + '@bufbuild/protobuf': 2.15.0 + peerDependenciesMeta: + '@bufbuild/protobuf': + optional: true + + '@bufbuild/protoplugin@2.15.0': + resolution: {integrity: sha512-vZhRoqB/jTHATqItRvJQf1zbxdrGRRhiusGty4+lKcfEFODG0PMXky2Ezv4WHHS6oSxq+5qShITuE7K1/82SuQ==} + + '@connectrpc/connect-node@2.1.1': + resolution: {integrity: sha512-s3TfsI1XF+n+1z6MBS9rTnFsxxR4Rw5wmdEnkQINli81ESGxcsfaEet8duzq8LVuuCupmhUsgpRo0Nv9pZkufg==} + engines: {node: '>=20'} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + '@connectrpc/connect': 2.1.1 + + '@connectrpc/connect@2.1.1': + resolution: {integrity: sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ==} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + + '@connectrpc/connect@2.2.0': + resolution: {integrity: sha512-M63OQ1ZFcPUm85CuewJ9G9CMV8LjzGR9935xOnXNQGSazAbwMb3x2W7vXBYM1zqrP7XFD8uxp/dUAhirclgwnQ==} + peerDependencies: + '@bufbuild/protobuf': ^2.7.0 + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/color-helpers@6.1.2': + resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-calc@3.4.1': + resolution: {integrity: sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.1 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@4.2.4': + resolution: {integrity: sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.1 + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@4.0.1': + resolution: {integrity: sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.2 + + '@csstools/css-syntax-patches-for-csstree@1.1.14': + resolution: {integrity: sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@4.0.2': + resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==} + engines: {node: '>=20.19.0'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.10.1': + resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.7': + resolution: {integrity: sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.5': + resolution: {integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@exodus/bytes@1.16.0': + resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==} + 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 + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + + '@napi-rs/wasm-runtime@1.2.4': + resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@next/env@16.3.6': + resolution: {integrity: sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ==} + + '@next/eslint-plugin-next@16.3.6': + resolution: {integrity: sha512-jowwDX+7DOlDIjJLgTMxudw+k37QnWu1JkZLkSi9MaJBfDYcfhAPMKBhXL0idYzFN/AGg//axnOR4cLkHX/Rng==} + + '@next/swc-darwin-arm64@16.3.6': + resolution: {integrity: sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.3.6': + resolution: {integrity: sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.3.6': + resolution: {integrity: sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@16.3.6': + resolution: {integrity: sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@16.3.6': + resolution: {integrity: sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@16.3.6': + resolution: {integrity: sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@16.3.6': + resolution: {integrity: sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.3.6': + resolution: {integrity: sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nolyfill/is-core-module@1.0.39': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/core@2.12.0': + resolution: {integrity: sha512-1HsSAuvT4/my0QrXWsyXFdWjKaPeCHsismKZgEEIb8NK13LP6DrMRlsUbvD/0F9mDWK3xDom9AMv3IscxgOnOQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-prometheus@0.223.0': + resolution: {integrity: sha512-HhbN3/B8vG+JNKH2oLMynFFrz2O/o+WFklfwiVq3ffdRWOEZx1zHeYe6r86fn9BUSpQ2U5cXfQja+otTdzi5qQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/resources@2.12.0': + resolution: {integrity: sha512-+rSRoOdln6NhXTVcBdHSlZxJDNzwxadeweK2ltZO3tVtfp6aklABc06xclDk6ovRR+EiGgIcSbh/6NgjFhnb3A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.12.0': + resolution: {integrity: sha512-mVMaEHwCUJuY3S4Esn+nT0yQA3qCEGh+SOPJsXygU0Gl453WcsDNjtEa8EfdewMwoLyr1tlofB4fXytVpiXuow==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + + '@pinojs/redact@0.4.0': + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + + '@playwright/test@1.55.1': + resolution: {integrity: sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==} + engines: {node: '>=18'} + hasBin: true + + '@rollup/rollup-android-arm-eabi@4.63.5': + resolution: {integrity: sha512-J25QJU+B78T4FhhBsNpLJyVWOi31mwtpcMwywHmOKH65Q9IWGA81gPj+dnwlhU8wktVriYE+tFAaQgrnJRzAZg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.5': + resolution: {integrity: sha512-LDopB3zuZM5Ux9TT2luNEBJW/tYbGU2g1d+VpKk6I+gSKDb+/7sYE6M225gRQt4RbMX6MSwMsVR/phdjVUgRLg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.5': + resolution: {integrity: sha512-wlJEERGfeuHeBavCL2qVnNacOK43NDoZM4sjkeRPymd04OAE9T1zBqDJgmZ+CIsPTYKwdzpUC8vmOw84dwY4Tg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.5': + resolution: {integrity: sha512-4nJJGg5jbo2wwPP4JP+LfEBA3bvP8rU9CLuhp7jWvq9sxEyhjQFTFdrqi+/dHEin/pd8jpT0vcehIpnZtmEdcQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.5': + resolution: {integrity: sha512-DrZbyCDF1hneuO6jRbvZ2D7+PIBM6yIwYnJpg2vIk58T+wuFpiaGZrfUr59lDWw45bg+IrpTGLPiNi/Fk4w3Cg==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.5': + resolution: {integrity: sha512-gqfUVMJMB3mehqywxp6hTBFfgtMQykZY19+cfiaYP0toIJLb/1DZRJHVkQQGP13W4TAwfZDWeg1qBcheTRioXQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.5': + resolution: {integrity: sha512-CFmhpvAwzSaWMlN3VN7UtmoTihlZNzoP0juQib5TQRnYUyDV8dXeWOp29sobWAT6gXl/hQgAClLlEiYozQG3OQ==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.63.5': + resolution: {integrity: sha512-Uc9H8eXCOayV6JLTH5bXKMId6qbhNHa818/BgYjm4jrlq3vZquC9cqyvHBw17xy5Mnj5f+I3gFK5JcEf3hSqrw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.63.5': + resolution: {integrity: sha512-VcPr/szv/1BFw112Kt//fxulXt/JPqzzidU84iW68L2DdjnOO8QFUv2zTSYBEPHD6movBD4z+bbr5y60GYM7Jw==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.63.5': + resolution: {integrity: sha512-BnxtJ5/91BrIHYIkGrmjz/lbMhqEHt1dPFqIxIFR+jPn0xVc/oUSCtIT089zfp5ufwGDlYz2UC+Fe1SRBpYFbQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.63.5': + resolution: {integrity: sha512-LrYcHZwF+fAMNKHYTOQ5osWM4AZF7YF6D+XtsjDyEvljtt11twc+zHVXBLNEjxVSUnKYsOhvVz4Z213eW02COQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.63.5': + resolution: {integrity: sha512-nj7QKQePAAUpCpJHtg0pR0W/b92A9NO17JS3BAQmHDn/yhmkir2p8llrKY9TOhleKIaSzy1JhxS3T9FVld6coA==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.63.5': + resolution: {integrity: sha512-5ylkX6dWMeBKge9nTU+Rxfb+ZfaCIJ9lRqIFaK0eAMcWp7OJbYnLveLgXmm0VrvuLKb8qIK+mHyH0qu88RM+iA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.63.5': + resolution: {integrity: sha512-oHK4ZHYFDKjZviK34I+NwgfbGxgI7ztrNxj2hPTSSNFgeq1a/lEd7dHV2fdGAuTH4Iym3RHJg+vAbWaWG4B7Zg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.63.5': + resolution: {integrity: sha512-UcetmHZ6XOXuUByiKZyQmb55ZPr0LABr3Ec/HB9wKZn6CEAFWZkE+hsJErJ9hbPBC7nI0dKuELx7CoV6IM7TMg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.63.5': + resolution: {integrity: sha512-C5CmDPQBtvjVo8cgQsBs+w6WB0JLkiixhgi6hVLV11hERWdn/p0XcPU2OUcZzac9BPOFq7SbaHFa8r3SWEysCQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.63.5': + resolution: {integrity: sha512-lHVQHJFKsuuxLMi3MQO9XVL8Tje3JR82CzB+QDKC5NWBcsIWuwsn9uIM5e3lBhI+fF1/s63qnyYqsg65+8rV/w==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.63.5': + resolution: {integrity: sha512-3W9bTFcQNJn71cSJVM9RKIiZOy8DO/XLDii8Uv/Pm6WKqDRj7JV3ZfuXIEfyuy5LXpIzAbB/1M4Ukp9GKNa7nA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.63.5': + resolution: {integrity: sha512-VDC7rRJlee/scpki96GZ27Omf6yU87s1YXwVTpjE5841faVlDYYT565rgfmoR1U0sqL7z5ivQSDjcsF6VRXyBA==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.63.5': + resolution: {integrity: sha512-z86Ok2p4pTdv5xqCKZsTooO7yBEiaJR/HzU3Wx8RmWsPoLppnMKROhJusQob8B3IE1ghC343kUW9rC2r+Wf3ig==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.5': + resolution: {integrity: sha512-IzQmj+xXwQFGhMAMKMQVXkMwMZN3TqkJgAE0nSsqvVwWWciP4AIPMmWRqOQ2GfX7TUDZr+xqGFcBS36CRPGw0g==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.5': + resolution: {integrity: sha512-F6qpTaPc9bwBH85kjy0/BLmLSW1uv7AoOXCoRIkg2arlgCYlWYcAbiMkvZuAcaWk9TpCRG//okznLAqLGshkMw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.5': + resolution: {integrity: sha512-igoDsTFhhwECBeGbUuLeIk7t8Y1apa+cs6mDWpx2EZ0ch7oEQgzHbFUXN9euoHekCAQzXdXApAGkV6jznS7tWw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.5': + resolution: {integrity: sha512-U3teMeMbXFmaM5D+OTJpsOXd+wV/qftIeYF9kBKL4v73641qyJmoXFtA28DQLsnmlyayEsTe72xpLHrArq6vHw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.5': + resolution: {integrity: sha512-ypfC34F3RKXvCXBglGqGMsUSMKlgwd1HX9AOAlx9RoZZ6GaI42YHVeKpzg3JG+wpBUJYTG+NNZhqbDWL8tBZkw==} + cpu: [x64] + os: [win32] + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@testing-library/dom@10.4.2': + resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.10.0': + resolution: {integrity: sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==} + engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + deprecated: Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0. + peerDependencies: + '@testing-library/dom': '>=10 <11' + + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/node@22.18.6': + resolution: {integrity: sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@typescript-eslint/eslint-plugin@8.71.0': + resolution: {integrity: sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.71.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.71.0': + resolution: {integrity: sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.71.0': + resolution: {integrity: sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.71.0': + resolution: {integrity: sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.71.0': + resolution: {integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.71.0': + resolution: {integrity: sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.71.0': + resolution: {integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.71.0': + resolution: {integrity: sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.71.0': + resolution: {integrity: sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.71.0': + resolution: {integrity: sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript/vfs@1.6.5': + resolution: {integrity: sha512-gobLHQLmvQGJInFk31NvgMd1uYzRXsd8qgnOssyLD055R2QPnw1ubRbBQ4+T+5qC7IQVuYXdvNDE2G7tMDVHgw==} + peerDependencies: + typescript: '*' + + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.12.2': + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.12.2': + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} + cpu: [loong64] + os: [linux] + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} + cpu: [loong64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} + cpu: [ppc64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} + cpu: [s390x] + os: [linux] + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} + cpu: [arm64] + os: [openharmony] + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} + cpu: [x64] + os: [win32] + + '@vitest/expect@4.0.7': + resolution: {integrity: sha512-jGRG6HghnJDjljdjYIoVzX17S6uCVCBRFnsgdLGJ6CaxfPh8kzUKe/2n533y4O/aeZ/sIr7q7GbuEbeGDsWv4Q==} + + '@vitest/mocker@4.0.7': + resolution: {integrity: sha512-OsDwLS7WnpuNslOV6bJkXVYVV/6RSc4eeVxV7h9wxQPNxnjRvTTrIikfwCbMyl8XJmW6oOccBj2Q07YwZtQcCw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.7': + resolution: {integrity: sha512-YY//yxqTmk29+/pK+Wi1UB4DUH3lSVgIm+M10rAJ74pOSMgT7rydMSc+vFuq9LjZLhFvVEXir8EcqMke3SVM6Q==} + + '@vitest/runner@4.0.7': + resolution: {integrity: sha512-orU1lsu4PxLEcDWfjVCNGIedOSF/YtZ+XMrd1PZb90E68khWCNzD8y1dtxtgd0hyBIQk8XggteKN/38VQLvzuw==} + + '@vitest/snapshot@4.0.7': + resolution: {integrity: sha512-xJL+Nkw0OjaUXXQf13B8iKK5pI9QVtN9uOtzNHYuG/o/B7fIEg0DQ+xOe0/RcqwDEI15rud1k7y5xznBKGUXAA==} + + '@vitest/spy@4.0.7': + resolution: {integrity: sha512-FW4X8hzIEn4z+HublB4hBF/FhCVaXfIHm8sUfvlznrcy1MQG7VooBgZPMtVCGZtHi0yl3KESaXTqsKh16d8cFg==} + + '@vitest/utils@4.0.7': + resolution: {integrity: sha512-HNrg9CM/Z4ZWB6RuExhuC6FPmLipiShKVMnT9JlQvfhwR47JatWLChA6mtZqVHqypE6p/z6ofcjbyWpM7YLxPQ==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.2.0: + resolution: {integrity: sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axe-core@4.10.3: + resolution: {integrity: sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg==} + engines: {node: '>=4'} + + axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} + engines: {node: '>=4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.11.26: + resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + bidi-js@1.1.0: + resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.29.2: + resolution: {integrity: sha512-/u9r8k8ue4ZhHCw9ovDtltpWdXeXzjhdxCGj6vm1RGLPXb8lu33EMdoCHF5Sx8oYBS+Q/FYYNmAqlHncxR3RmA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001813: + resolution: {integrity: sha512-zfjJo4rM0+fUomGDBW/xcDjhIwz/210DGvip2MAMDZ8KHcRPnOHmEgHPZP0UHlZoxr8fYKVJOqcORhYQcG4FKQ==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + cssstyle@5.3.7: + resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} + engines: {node: '>=20'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + data-urls@6.0.1: + resolution: {integrity: sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==} + engines: {node: '>=20'} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.439: + resolution: {integrity: sha512-qu6QIPXhsb+CRcAiTMNjR4A1y/7tCYKkKjr5CZXRVih6qkDf79peZ2BpEU3qoDBNCRrcy3Mra3X9nG5oruuA7Q==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@8.1.0: + resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} + engines: {node: '>=20.19.0'} + + es-abstract-get@1.0.0: + resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} + engines: {node: '>= 0.4'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.4.0: + resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.4: + resolution: {integrity: sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==} + engines: {node: '>= 0.4'} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-next@16.3.6: + resolution: {integrity: sha512-1Upt3U7BDwU+ilpe2byZjAfts9oNq4d4fv/zXEvs8/4yS+cwOQW/WCxUNy8gCDquX67SzeehDvKblVC6ZBMocQ==} + peerDependencies: + eslint: '>=9.0.0' + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true + + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} + + eslint-import-resolver-typescript@3.10.1: + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.14.0: + resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-jsx-a11y@6.10.2: + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.5: + resolution: {integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.1: + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.3: + resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.2.0: + resolution: {integrity: sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.4.0: + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.10: + resolution: {integrity: sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-bun-module@2.0.0: + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.17.0: + resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-document.all@1.0.0: + resolution: {integrity: sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + jsdom@26.1.0: + resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsdom@27.4.0: + resolution: {integrity: sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + next@16.3.6: + resolution: {integrity: sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-exports-info@1.6.2: + resolution: {integrity: sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag==} + engines: {node: '>= 0.4'} + + node-releases@2.0.57: + resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} + engines: {node: '>=18'} + + nwsapi@2.2.28: + resolution: {integrity: sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.2: + resolution: {integrity: sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pino-abstract-transport@3.0.0: + resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} + + pino-std-serializers@7.1.0: + resolution: {integrity: sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==} + + pino@10.4.0: + resolution: {integrity: sha512-bk1ZMTwG/Vymx+zPoa28MhNzucFvXJWp3csrSuxuHPSMeUettaCoQ5Jvg07mtC4JGNpcfTwz41oVCnNgBK9+vA==} + hasBin: true + + playwright-core@1.55.1: + resolution: {integrity: sha512-Z6Mh9mkwX+zxSlHqdr5AOcJnfp+xUWLCt9uKV18fhzA8eyxUd8NUWzAjxUh55RZKSYwDGX0cfaySdhZJGMoJ+w==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.55.1: + resolution: {integrity: sha512-cJW4Xd/G3v5ovXtJJ52MAOclqeac9S/aGGgRzLabuF8TnIb6xHvMzKIa6JmrRzUkeXJgfL1MhukP0NK6l39h3A==} + engines: {node: '>=18'} + hasBin: true + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + process-warning@5.1.0: + resolution: {integrity: sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} + peerDependencies: + react: ^19.3.0 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + engines: {node: '>=0.10.0'} + + real-require@1.0.0: + resolution: {integrity: sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==} + + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@2.0.0-next.7: + resolution: {integrity: sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.63.5: + resolution: {integrity: sha512-KRWwmNLlPw5M7HcdYfm15oBv9n9LPtjzpzCIxS/phwqvPyxHSoKX6Y2YU3pxSPfy0CLquVgsx/j/hBi6OvH1Nw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sonic-boom@4.2.1: + resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + stable-hash@0.0.5: + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.1.0: + resolution: {integrity: sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.11: + resolution: {integrity: sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.10: + resolution: {integrity: sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + thread-stream@4.2.0: + resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} + engines: {node: '>=20'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts-core@7.4.16: + resolution: {integrity: sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tldts@7.4.16: + resolution: {integrity: sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.5: + resolution: {integrity: sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.8: + resolution: {integrity: sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==} + engines: {node: '>= 0.4'} + + typescript-eslint@8.71.0: + resolution: {integrity: sha512-fBdHYiqQ14RW6mOMXD14Svn82ZsCYAoQSzGRzyEjR59S5A2Krh/l7fGTOQ7iCr8gGy/mHVXtEF7s5fgjEdV0Pw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.4.5: + resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} + engines: {node: '>=14.17'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unrs-resolver@1.12.2: + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.0.7: + resolution: {integrity: sha512-xQroKAadK503CrmbzCISvQUjeuvEZzv6U0wlnlVFOi5i3gnzfH4onyQ29f3lzpe0FresAiTAd3aqK0Bi/jLI8w==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.7 + '@vitest/browser-preview': 4.0.7 + '@vitest/browser-webdriverio': 4.0.7 + '@vitest/ui': 4.0.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + whatwg-url@15.1.0: + resolution: {integrity: sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==} + engines: {node: '>=20'} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.24: + resolution: {integrity: sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.6.5: + resolution: {integrity: sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==} + +snapshots: + + '@acemir/cssom@0.9.31': + optional: true + + '@adobe/css-tools@4.5.0': {} + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@asamuzakjp/css-color@4.1.2': + dependencies: + '@csstools/css-calc': 3.4.1(@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-color-parser': 4.2.4(@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.1(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + lru-cache: 11.5.3 + optional: true + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.1.0 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.3 + optional: true + + '@asamuzakjp/nwsapi@2.3.9': + optional: true + + '@axe-core/playwright@4.10.2(playwright-core@1.55.1)': + dependencies: + axe-core: 4.10.3 + playwright-core: 1.55.1 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.29.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.9 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bufbuild/buf-darwin-arm64@1.73.0': + optional: true + + '@bufbuild/buf-darwin-x64@1.73.0': + optional: true + + '@bufbuild/buf-linux-aarch64@1.73.0': + optional: true + + '@bufbuild/buf-linux-armv7@1.73.0': + optional: true + + '@bufbuild/buf-linux-x64@1.73.0': + optional: true + + '@bufbuild/buf-win32-arm64@1.73.0': + optional: true + + '@bufbuild/buf-win32-x64@1.73.0': + optional: true + + '@bufbuild/buf@1.73.0': + optionalDependencies: + '@bufbuild/buf-darwin-arm64': 1.73.0 + '@bufbuild/buf-darwin-x64': 1.73.0 + '@bufbuild/buf-linux-aarch64': 1.73.0 + '@bufbuild/buf-linux-armv7': 1.73.0 + '@bufbuild/buf-linux-x64': 1.73.0 + '@bufbuild/buf-win32-arm64': 1.73.0 + '@bufbuild/buf-win32-x64': 1.73.0 + + '@bufbuild/protobuf@2.15.0': {} + + '@bufbuild/protoc-gen-es@2.15.0(@bufbuild/protobuf@2.15.0)': + dependencies: + '@bufbuild/protoplugin': 2.15.0 + optionalDependencies: + '@bufbuild/protobuf': 2.15.0 + transitivePeerDependencies: + - supports-color + + '@bufbuild/protoplugin@2.15.0': + dependencies: + '@bufbuild/protobuf': 2.15.0 + '@typescript/vfs': 1.6.5(typescript@5.4.5) + typescript: 5.4.5 + transitivePeerDependencies: + - supports-color + + '@connectrpc/connect-node@2.1.1(@bufbuild/protobuf@2.15.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.15.0))': + dependencies: + '@bufbuild/protobuf': 2.15.0 + '@connectrpc/connect': 2.1.1(@bufbuild/protobuf@2.15.0) + + '@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.15.0)': + dependencies: + '@bufbuild/protobuf': 2.15.0 + + '@connectrpc/connect@2.2.0(@bufbuild/protobuf@2.15.0)': + dependencies: + '@bufbuild/protobuf': 2.15.0 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/color-helpers@6.1.2': + optional: true + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-calc@3.4.1(@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.1(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + optional: true + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@4.2.4(@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/color-helpers': 6.1.2 + '@csstools/css-calc': 3.4.1(@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2) + '@csstools/css-parser-algorithms': 4.0.1(@csstools/css-tokenizer@4.0.2) + '@csstools/css-tokenizer': 4.0.2 + optional: true + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@4.0.1(@csstools/css-tokenizer@4.0.2)': + dependencies: + '@csstools/css-tokenizer': 4.0.2 + optional: true + + '@csstools/css-syntax-patches-for-csstree@1.1.14(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + optional: true + + '@csstools/css-tokenizer@3.0.4': {} + + '@csstools/css-tokenizer@4.0.2': + optional: true + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5)': + dependencies: + eslint: 9.39.5 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.5)': + dependencies: + eslint: 9.39.5 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.7': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.2 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.5': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@exodus/bytes@1.16.0': + optional: true + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.4 + optional: true + + '@img/sharp-darwin-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.4 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + optional: true + + '@img/sharp-linux-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.4 + optional: true + + '@img/sharp-linux-arm@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.4 + optional: true + + '@img/sharp-linux-ppc64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.4 + optional: true + + '@img/sharp-linux-riscv64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.4 + optional: true + + '@img/sharp-linux-s390x@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.4 + optional: true + + '@img/sharp-linux-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + optional: true + + '@img/sharp-wasm32@0.35.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-win32-arm64@0.35.5': + optional: true + + '@img/sharp-win32-ia32@0.35.5': + optional: true + + '@img/sharp-win32-x64@0.35.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@next/env@16.3.6': {} + + '@next/eslint-plugin-next@16.3.6(eslint@9.39.5)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.5) + fast-glob: 3.3.1 + transitivePeerDependencies: + - eslint + + '@next/swc-darwin-arm64@16.3.6': + optional: true + + '@next/swc-darwin-x64@16.3.6': + optional: true + + '@next/swc-linux-arm64-gnu@16.3.6': + optional: true + + '@next/swc-linux-arm64-musl@16.3.6': + optional: true + + '@next/swc-linux-x64-gnu@16.3.6': + optional: true + + '@next/swc-linux-x64-musl@16.3.6': + optional: true + + '@next/swc-win32-arm64-msvc@16.3.6': + optional: true + + '@next/swc-win32-x64-msvc@16.3.6': + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.3 + + '@nolyfill/is-core-module@1.0.39': {} + + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/core@2.12.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/exporter-prometheus@0.223.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.12.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.12.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/resources@2.12.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.0) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-metrics@2.12.0(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/api': 1.9.0 + '@opentelemetry/core': 2.12.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.12.0(@opentelemetry/api@1.9.0) + + '@opentelemetry/semantic-conventions@1.43.0': {} + + '@pinojs/redact@0.4.0': {} + + '@playwright/test@1.55.1': + dependencies: + playwright: 1.55.1 + + '@rollup/rollup-android-arm-eabi@4.63.5': + optional: true + + '@rollup/rollup-android-arm64@4.63.5': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.5': + optional: true + + '@rollup/rollup-darwin-x64@4.63.5': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.5': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.5': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.5': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.5': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.5': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.5': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.5': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.5': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.5': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.5': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.5': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.5': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.5': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.5': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.5': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.5': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.5': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.5': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.5': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.5': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.5': + optional: true + + '@rtsao/scc@1.1.0': {} + + '@standard-schema/spec@1.1.0': {} + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@testing-library/dom@10.4.2': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.10.0(@testing-library/dom@10.4.2)': + dependencies: + '@adobe/css-tools': 4.5.0 + '@testing-library/dom': 10.4.2 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.2 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/node@22.18.6': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.71.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.71.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/type-utils': 8.71.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.71.0 + eslint: 9.39.5 + ignore: 7.0.10 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.71.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.71.0 + debug: 4.4.3 + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.71.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@5.9.3) + '@typescript-eslint/types': 8.71.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.71.0': + dependencies: + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + + '@typescript-eslint/tsconfig-utils@8.71.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.71.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.0(eslint@9.39.5)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.5 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.71.0': {} + + '@typescript-eslint/typescript-estree@8.71.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.71.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@5.9.3) + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/visitor-keys': 8.71.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.71.0(eslint@9.39.5)(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) + '@typescript-eslint/scope-manager': 8.71.0 + '@typescript-eslint/types': 8.71.0 + '@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3) + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.71.0': + dependencies: + '@typescript-eslint/types': 8.71.0 + eslint-visitor-keys: 5.0.1 + + '@typescript/vfs@1.6.5(typescript@5.4.5)': + dependencies: + debug: 4.4.3 + typescript: 5.4.5 + transitivePeerDependencies: + - supports-color + + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + optional: true + + '@unrs/resolver-binding-android-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + optional: true + + '@vitest/expect@4.0.7': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.7 + '@vitest/utils': 4.0.7 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.0.7(vite@7.3.6(@types/node@22.18.6))': + dependencies: + '@vitest/spy': 4.0.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@22.18.6) + + '@vitest/pretty-format@4.0.7': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.0.7': + dependencies: + '@vitest/utils': 4.0.7 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.7': + dependencies: + '@vitest/pretty-format': 4.0.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.7': {} + + '@vitest/utils@4.0.7': + dependencies: + '@vitest/pretty-format': 4.0.7 + tinyrainbow: 3.1.1 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + agent-base@7.1.4: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + argparse@2.0.1: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + assertion-error@2.0.1: {} + + ast-types-flow@0.0.8: {} + + async-function@1.0.0: {} + + atomic-sleep@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axe-core@4.10.3: {} + + axe-core@4.13.0: {} + + axobject-query@4.1.0: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.11.26: {} + + bidi-js@1.1.0: + dependencies: + require-from-string: 2.0.2 + optional: true + + brace-expansion@1.1.21: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.29.2: + dependencies: + baseline-browser-mapping: 2.11.26 + caniuse-lite: 1.0.30001813 + electron-to-chromium: 1.5.439 + node-releases: 2.0.57 + update-browserslist-db: 1.3.3(browserslist@4.29.2) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001813: {} + + chai@6.2.2: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + client-only@0.0.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + optional: true + + css.escape@1.5.1: {} + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + cssstyle@5.3.7: + dependencies: + '@asamuzakjp/css-color': 4.1.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.14(css-tree@3.2.1) + css-tree: 3.2.1 + lru-cache: 11.5.3 + optional: true + + csstype@3.2.3: {} + + damerau-levenshtein@1.0.8: {} + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + data-urls@6.0.1: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 15.1.0 + optional: true + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + dequal@2.0.3: {} + + detect-libc@2.1.2: + optional: true + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.439: {} + + emoji-regex@9.2.2: {} + + entities@6.0.1: {} + + entities@8.1.0: + optional: true + + es-abstract-get@1.0.0: + dependencies: + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + is-callable: 1.2.7 + object-inspect: 1.13.4 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.4 + function.prototype.name: 1.2.0 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.2 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.11 + string.prototype.trimend: 1.0.10 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.5 + typed-array-length: 1.0.8 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.24 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.4.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.4 + + es-to-primitive@1.3.4: + dependencies: + es-abstract-get: 1.0.0 + es-define-property: 1.0.1 + es-errors: 1.3.0 + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-next@16.3.6(@typescript-eslint/parser@8.71.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3): + dependencies: + '@next/eslint-plugin-next': 16.3.6(eslint@9.39.5) + eslint: 9.39.5 + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5))(eslint@9.39.5) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.5) + eslint-plugin-react: 7.37.5(eslint@9.39.5) + eslint-plugin-react-hooks: 7.1.1(eslint@9.39.5) + globals: 16.4.0 + typescript-eslint: 8.71.0(eslint@9.39.5)(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + + eslint-import-resolver-node@0.3.10: + dependencies: + debug: 3.2.7 + is-core-module: 2.17.0 + resolve: 2.0.0-next.7 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5))(eslint@9.39.5): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.5 + get-tsconfig: 4.14.3 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.17 + unrs-resolver: 1.12.2 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5))(eslint@9.39.5))(eslint@9.39.5): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.71.0(eslint@9.39.5)(typescript@5.9.3) + eslint: 9.39.5 + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5))(eslint@9.39.5) + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.5): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.2.0 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.5 + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5))(eslint@9.39.5))(eslint@9.39.5) + hasown: 2.0.4 + is-core-module: 2.17.0 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.10 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.71.0(eslint@9.39.5)(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5): + dependencies: + aria-query: 5.3.2 + array-includes: 3.2.0 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.13.0 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 9.39.5 + hasown: 2.0.4 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + + eslint-plugin-react-hooks@7.1.1(eslint@9.39.5): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.9 + eslint: 9.39.5 + hermes-parser: 0.25.1 + zod: 4.6.5 + zod-validation-error: 4.0.2(zod@4.6.5) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react@7.37.5(eslint@9.39.5): + dependencies: + array-includes: 3.2.0 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.4.0 + eslint: 9.39.5 + estraverse: 5.3.0 + hasown: 2.0.4 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.7 + semver: 6.3.1 + string.prototype.matchall: 4.1.0 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5: + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.7 + '@eslint/js': 9.39.5 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.1: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.3: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + function.prototype.name@1.2.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + es-define-property: 1.0.1 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + hasown: 2.0.4 + is-callable: 1.2.7 + is-document.all: 1.0.0 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.4.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.16.0 + transitivePeerDependencies: + - '@noble/hashes' + optional: true + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.10: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.4 + side-channel: 1.1.1 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-bun-module@2.0.0: + dependencies: + semver: 7.8.5 + + is-callable@1.2.7: {} + + is-core-module@2.17.0: + dependencies: + hasown: 2.0.4 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-document.all@1.0.0: + dependencies: + call-bound: 1.0.4 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.24 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + js-tokens@4.0.0: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + jsdom@26.1.0: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.28 + parse5: 7.3.0 + rrweb-cssom: 0.8.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.22.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsdom@27.4.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@exodus/bytes': 1.16.0 + cssstyle: 5.3.7 + data-urls: 6.0.1 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 15.1.0 + ws: 8.22.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - bufferutil + - supports-color + - utf-8-validate + optional: true + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.2.0 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@10.4.3: {} + + lru-cache@11.5.3: + optional: true + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + math-intrinsics@1.1.0: {} + + mdn-data@2.27.1: + optional: true + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + min-indent@1.0.1: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.21 + + minimist@1.2.8: {} + + ms@2.1.3: {} + + nanoid@3.3.19: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + next@16.3.6(@babel/core@7.29.7)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.1)(@types/node@22.18.6)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + dependencies: + '@next/env': 16.3.6 + '@swc/helpers': 0.5.23 + baseline-browser-mapping: 2.11.26 + caniuse-lite: 1.0.30001813 + postcss: 8.5.23 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.3.0) + optionalDependencies: + '@next/swc-darwin-arm64': 16.3.6 + '@next/swc-darwin-x64': 16.3.6 + '@next/swc-linux-arm64-gnu': 16.3.6 + '@next/swc-linux-arm64-musl': 16.3.6 + '@next/swc-linux-x64-gnu': 16.3.6 + '@next/swc-linux-x64-musl': 16.3.6 + '@next/swc-win32-arm64-msvc': 16.3.6 + '@next/swc-win32-x64-msvc': 16.3.6 + '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.55.1 + sharp: 0.35.5(@types/node@22.18.6) + transitivePeerDependencies: + - '@babel/core' + - '@types/node' + - babel-plugin-macros + + node-exports-info@1.6.2: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-releases@2.0.57: {} + + nwsapi@2.2.28: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + on-exit-leak-free@2.1.2: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + parse5@8.0.1: + dependencies: + entities: 8.1.0 + optional: true + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.7: {} + + pino-abstract-transport@3.0.0: + dependencies: + split2: 4.2.0 + + pino-std-serializers@7.1.0: {} + + pino@10.4.0: + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 3.0.0 + pino-std-serializers: 7.1.0 + process-warning: 5.1.0 + quick-format-unescaped: 4.0.4 + real-require: 1.0.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.1 + thread-stream: 4.2.0 + + playwright-core@1.55.1: {} + + playwright@1.55.1: + dependencies: + playwright-core: 1.55.1 + optionalDependencies: + fsevents: 2.3.2 + + possible-typed-array-names@1.1.0: {} + + postcss@8.5.23: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + process-warning@5.1.0: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + quick-format-unescaped@4.0.4: {} + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-dom@19.3.0(react@19.3.0): + dependencies: + react: 19.3.0 + scheduler: 0.28.0 + + react-is@16.13.1: {} + + react-is@17.0.2: {} + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + react@19.3.0: {} + + real-require@1.0.0: {} + + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + require-from-string@2.0.2: + optional: true + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@2.0.0-next.7: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.17.0 + node-exports-info: 1.6.2 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rollup@4.63.5: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.5 + '@rollup/rollup-android-arm64': 4.63.5 + '@rollup/rollup-darwin-arm64': 4.63.5 + '@rollup/rollup-darwin-x64': 4.63.5 + '@rollup/rollup-freebsd-arm64': 4.63.5 + '@rollup/rollup-freebsd-x64': 4.63.5 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.5 + '@rollup/rollup-linux-arm-musleabihf': 4.63.5 + '@rollup/rollup-linux-arm64-gnu': 4.63.5 + '@rollup/rollup-linux-arm64-musl': 4.63.5 + '@rollup/rollup-linux-loong64-gnu': 4.63.5 + '@rollup/rollup-linux-loong64-musl': 4.63.5 + '@rollup/rollup-linux-ppc64-gnu': 4.63.5 + '@rollup/rollup-linux-ppc64-musl': 4.63.5 + '@rollup/rollup-linux-riscv64-gnu': 4.63.5 + '@rollup/rollup-linux-riscv64-musl': 4.63.5 + '@rollup/rollup-linux-s390x-gnu': 4.63.5 + '@rollup/rollup-linux-x64-gnu': 4.63.5 + '@rollup/rollup-linux-x64-musl': 4.63.5 + '@rollup/rollup-openbsd-x64': 4.63.5 + '@rollup/rollup-openharmony-arm64': 4.63.5 + '@rollup/rollup-win32-arm64-msvc': 4.63.5 + '@rollup/rollup-win32-ia32-msvc': 4.63.5 + '@rollup/rollup-win32-x64-gnu': 4.63.5 + '@rollup/rollup-win32-x64-msvc': 4.63.5 + fsevents: 2.3.3 + + rrweb-cssom@0.8.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + safe-stable-stringify@2.5.0: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + scheduler@0.28.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + server-only@0.0.1: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + + sharp@0.35.5(@types/node@22.18.6): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 22.18.6 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + sonic-boom@4.2.1: + dependencies: + atomic-sleep: 1.0.0 + + source-map-js@1.2.1: {} + + split2@4.2.0: {} + + stable-hash@0.0.5: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.includes@2.0.1: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.matchall@4.1.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.1 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.trim@1.2.11: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.2 + has-property-descriptors: 1.0.2 + safe-regex-test: 1.1.0 + + string.prototype.trimend@1.0.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.2 + + strip-bom@3.0.0: {} + + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + + strip-json-comments@3.1.1: {} + + styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.3.0): + dependencies: + client-only: 0.0.1 + react: 19.3.0 + optionalDependencies: + '@babel/core': 7.29.7 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + symbol-tree@3.2.4: {} + + thread-stream@4.2.0: + dependencies: + real-require: 1.0.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinyrainbow@3.1.1: {} + + tldts-core@6.1.86: {} + + tldts-core@7.4.16: + optional: true + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tldts@7.4.16: + dependencies: + tldts-core: 7.4.16 + optional: true + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.16 + optional: true + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + optional: true + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.5: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.8: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript-eslint@8.71.0(eslint@9.39.5)(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.71.0(@typescript-eslint/parser@8.71.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/parser': 8.71.0(eslint@9.39.5)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.71.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.71.0(eslint@9.39.5)(typescript@5.9.3) + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.4.5: {} + + typescript@5.9.3: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@6.21.0: {} + + unrs-resolver@1.12.2: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.12.2 + '@unrs/resolver-binding-android-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-x64': 1.12.2 + '@unrs/resolver-binding-freebsd-x64': 1.12.2 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + + update-browserslist-db@1.3.3(browserslist@4.29.2): + dependencies: + browserslist: 4.29.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@7.3.6(@types/node@22.18.6): + dependencies: + esbuild: 0.28.2 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.28 + rollup: 4.63.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.18.6 + fsevents: 2.3.3 + + vitest@4.0.7(@types/node@22.18.6)(jsdom@26.1.0): + dependencies: + '@vitest/expect': 4.0.7 + '@vitest/mocker': 4.0.7(vite@7.3.6(@types/node@22.18.6)) + '@vitest/pretty-format': 4.0.7 + '@vitest/runner': 4.0.7 + '@vitest/snapshot': 4.0.7 + '@vitest/spy': 4.0.7 + '@vitest/utils': 4.0.7 + debug: 4.4.3 + es-module-lexer: 1.7.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 7.3.6(@types/node@22.18.6) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.18.6 + jsdom: 26.1.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vitest@4.0.7(@types/node@22.18.6)(jsdom@27.4.0): + dependencies: + '@vitest/expect': 4.0.7 + '@vitest/mocker': 4.0.7(vite@7.3.6(@types/node@22.18.6)) + '@vitest/pretty-format': 4.0.7 + '@vitest/runner': 4.0.7 + '@vitest/snapshot': 4.0.7 + '@vitest/spy': 4.0.7 + '@vitest/utils': 4.0.7 + debug: 4.4.3 + es-module-lexer: 1.7.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.7 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 7.3.6(@types/node@22.18.6) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.18.6 + jsdom: 27.4.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + webidl-conversions@8.0.1: + optional: true + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-mimetype@5.0.0: + optional: true + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + whatwg-url@15.1.0: + dependencies: + tr46: 6.0.0 + webidl-conversions: 8.0.1 + optional: true + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.2.0 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.24 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.24: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + ws@8.22.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.6.5): + dependencies: + zod: 4.6.5 + + zod@4.6.5: {} diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml new file mode 100644 index 000000000..a7968ee15 --- /dev/null +++ b/web/pnpm-workspace.yaml @@ -0,0 +1,13 @@ +packages: + - api + - service/* + - submitqueue + - test/package/react18 + +hoist: false + +onlyBuiltDependencies: + - '@bufbuild/buf' + - esbuild + +linkWorkspacePackages: true diff --git a/web/service/submitqueue/.gitignore b/web/service/submitqueue/.gitignore new file mode 100644 index 000000000..5c560c0c2 --- /dev/null +++ b/web/service/submitqueue/.gitignore @@ -0,0 +1,3 @@ +.next/ +node_modules/ +tsconfig.tsbuildinfo diff --git a/web/service/submitqueue/BUILD.bazel b/web/service/submitqueue/BUILD.bazel new file mode 100644 index 000000000..222e4698e --- /dev/null +++ b/web/service/submitqueue/BUILD.bazel @@ -0,0 +1,188 @@ +load("@aspect_rules_js//contrib/nextjs:defs.bzl", "nextjs_standalone_build", "nextjs_standalone_server") +load("@aspect_rules_js//js:defs.bzl", "js_image_layer") +load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project") +load("@npm//:defs.bzl", "npm_link_all_packages") +load("@npm//web/service/submitqueue:eslint/package_json.bzl", eslint = "bin") +load("@npm//web/service/submitqueue:next/package_json.bzl", next = "bin") +load("@npm//web/service/submitqueue:vitest/package_json.bzl", vitest = "bin") +load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load") + +package(default_visibility = ["//web:__subpackages__"]) + +npm_link_all_packages(name = "node_modules") + +ts_config( + name = "tsconfig", + src = "tsconfig.json", +) + +ts_project( + name = "typecheck", + srcs = glob([ + "src/**/*.ts", + "src/**/*.tsx", + ]) + [ + "next-env.d.ts", + "next.config.ts", + "vitest.config.ts", + ], + declaration = False, + incremental = True, + no_emit = True, + tsconfig = ":tsconfig", + deps = [ + ":node_modules/@bufbuild/protobuf", + ":node_modules/@connectrpc/connect", + ":node_modules/@connectrpc/connect-node", + ":node_modules/@opentelemetry/api", + ":node_modules/@opentelemetry/exporter-prometheus", + ":node_modules/@opentelemetry/sdk-metrics", + ":node_modules/@submitqueue/api", + ":node_modules/@submitqueue/web-submitqueue", + ":node_modules/@types/node", + ":node_modules/pino", + ":node_modules/@types/react", + ":node_modules/@types/react-dom", + ":node_modules/next", + ":node_modules/react", + ":node_modules/react-dom", + ":node_modules/server-only", + ":node_modules/vitest", + ], +) + +eslint.eslint_test( + name = "lint", + args = ["."], + chdir = package_name(), + data = [ + ":node_modules", + ":node_modules/eslint", + ":node_modules/eslint-config-next", + "//web:node_modules/next", + "eslint.config.mjs", + "next-env.d.ts", + "next.config.ts", + "package.json", + "tsconfig.json", + "vitest.config.ts", + ] + glob([ + "src/**/*.ts", + "src/**/*.tsx", + ]), +) + +vitest.vitest_test( + name = "test", + args = [ + "run", + "--config", + "vitest.config.ts", + ], + chdir = package_name(), + data = [ + ":node_modules", + ":node_modules/vitest", + "//web:node_modules/@connectrpc/connect", + "//web:node_modules/@connectrpc/connect-node", + "//web:node_modules/@opentelemetry/api", + "//web:node_modules/next", + "//web:node_modules/pino", + "//web:node_modules/react", + "//web:node_modules/react-dom", + "vitest.config.ts", + ] + glob([ + "src/**/*.ts", + "src/**/*.tsx", + ]), +) + +next.next_binary( + name = "next", +) + +nextjs_standalone_build( + name = "standalone", + args = ["--webpack"], + config = "next.config.ts", + data = [ + ":node_modules", + ":node_modules/@bufbuild/protobuf", + ":node_modules/@connectrpc/connect", + ":node_modules/@connectrpc/connect-node", + ":node_modules/@opentelemetry/api", + ":node_modules/@opentelemetry/exporter-prometheus", + ":node_modules/@opentelemetry/sdk-metrics", + ":node_modules/@submitqueue/api", + ":node_modules/@submitqueue/web-submitqueue", + ":node_modules/next", + ":node_modules/react", + ":node_modules/react-dom", + ":node_modules/server-only", + "//web:node_modules/@connectrpc/connect", + "//web:node_modules/@connectrpc/connect-node", + "//web:node_modules/@opentelemetry/api", + "//web:node_modules/next", + "//web:node_modules/pino", + "//web:node_modules/react", + "//web:node_modules/react-dom", + "//web/submitqueue:lib", + "//web/submitqueue:lib_types", + "package.json", + "tsconfig.json", + ], + env = {"NEXT_TELEMETRY_DISABLED": "1"}, + next_js_binary = ":next", + srcs = ["next-env.d.ts"] + glob([ + "src/**", + ]), +) + +nextjs_standalone_server( + name = "server", + app = ":standalone", + # Next.js leaves pino unbundled, so the runtime layer must supply it. + data = [ + ":node_modules/next", + ":node_modules/pino", + ":node_modules/react", + ":node_modules/react-dom", + ":node_modules/server-only", + ], + pkg = "service/submitqueue", +) + +platform( + name = "linux_amd64", + constraint_values = [ + "@platforms//cpu:x86_64", + "@platforms//os:linux", + ], +) + +js_image_layer( + name = "server_layers", + binary = ":server", + platform = ":linux_amd64", + root = "/app", +) + +oci_image( + name = "image", + base = "@node_22_20_0_bookworm_slim_linux_amd64", + entrypoint = ["/app/web/service/submitqueue/server"], + env = { + "HOSTNAME": "0.0.0.0", + "NODE_ENV": "production", + "PORT": "3000", + }, + tars = [":server_layers"], + user = "node", + workdir = "/app/web/service/submitqueue/server.runfiles/_main", +) + +oci_load( + name = "image_load", + image = ":image", + repo_tags = ["submitqueue-web-service:latest"], +) diff --git a/web/service/submitqueue/README.md b/web/service/submitqueue/README.md new file mode 100644 index 000000000..263211a94 --- /dev/null +++ b/web/service/submitqueue/README.md @@ -0,0 +1,28 @@ +# SubmitQueue web reference host + +A local/demo Next.js host for the SubmitQueue module, and the composition root that chooses every implementation (like a Go service's `server/main.go`). Not a production authentication or deployment contract. + +| Path | Holds | +|---|---| +| `src/server/` | Wiring, free of Next.js: environment config, demo Basic auth, the Connect gateway client and transport, pino, opt-in Prometheus, and `resolveReferenceWebHost` | +| `src/next/` | Everything Next.js-specific: catch-all page and metadata, the per-request auth check, navigation provider (`next/link` without prefetch, transition-aware refresh), readiness route | +| `src/app/` | Routes: one optional catch-all page, `/healthz`, layouts, not-found and unauthorized pages | +| `src/proxy.ts` | Challenges anonymous browsers with HTTP Basic auth | + +Two Next.js specifics: `next.config.ts` sets `htmlLimitedBots: /.*/` so page titles resolve before streaming, and Next leaves pino unbundled, so the image's runtime layer declares it. + +| Variable | Meaning | +|---|---| +| `SUBMITQUEUE_GATEWAY_URL` | Gateway URL (TLS unless plaintext is allowed) | +| `SUBMITQUEUE_GATEWAY_ALLOW_PLAINTEXT` | `true` to allow local h2c | +| `SUBMITQUEUE_WEB_TOKEN` | Basic-auth password for user `test` (demo default `test`) | +| `SUBMITQUEUE_WEB_CURSOR_SECRET` | Key that signs pagination cursors (demo default `local-demo-cursor-secret`) | +| `SUBMITQUEUE_WEB_METRICS_PORT` | Optional Prometheus port | +| `LOG_LEVEL` | pino level, default `info` | + +The OCI image is built by Bazel without a Dockerfile (`:image`, loaded as `submitqueue-web-service:latest` by `make web-image-load`), so local Compose, CI, and E2E run the same artifact. + +```bash +./tool/bazel test //web/service/submitqueue:all +make local-submitqueue-start +``` diff --git a/web/service/submitqueue/eslint.config.mjs b/web/service/submitqueue/eslint.config.mjs new file mode 100644 index 000000000..db21edbe5 --- /dev/null +++ b/web/service/submitqueue/eslint.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; + +export default defineConfig([ + ...nextVitals, + globalIgnores([".next/**", "node_modules/**"]), +]); diff --git a/web/service/submitqueue/next-env.d.ts b/web/service/submitqueue/next-env.d.ts new file mode 100644 index 000000000..ce4e94a6b --- /dev/null +++ b/web/service/submitqueue/next-env.d.ts @@ -0,0 +1,7 @@ +/// +/// +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/web/service/submitqueue/next.config.ts b/web/service/submitqueue/next.config.ts new file mode 100644 index 000000000..bdddb6bf7 --- /dev/null +++ b/web/service/submitqueue/next.config.ts @@ -0,0 +1,19 @@ +import type { NextConfig } from "next"; +import path from "node:path"; + +const nextConfig: NextConfig = { + experimental: { + authInterrupts: true, + }, + // Pages already wait for their data, so resolve titles before streaming rather + // than leaving the document untitled until streamed metadata arrives. + htmlLimitedBots: /.*/, + output: "standalone", + outputFileTracingRoot: path.join(import.meta.dirname, "../.."), + outputFileTracingExcludes: { + "*": ["**/node_modules/**"], + }, + transpilePackages: ["@submitqueue/api", "@submitqueue/web-submitqueue"], +}; + +export default nextConfig; diff --git a/web/service/submitqueue/package.json b/web/service/submitqueue/package.json new file mode 100644 index 000000000..d7df6d6cf --- /dev/null +++ b/web/service/submitqueue/package.json @@ -0,0 +1,38 @@ +{ + "name": "@submitqueue/service-submitqueue-web", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "next build", + "dev": "next dev --hostname 0.0.0.0 --port 3000", + "lint": "eslint .", + "start": "next start --hostname 0.0.0.0 --port 3000", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@bufbuild/protobuf": "2.15.0", + "@connectrpc/connect": "^2.1.1", + "@connectrpc/connect-node": "^2.1.1", + "@opentelemetry/api": "1.9.0", + "@opentelemetry/exporter-prometheus": "^0.223.0", + "@opentelemetry/sdk-metrics": "^2.12.0", + "@submitqueue/api": "0.1.0", + "@submitqueue/web-submitqueue": "0.0.0", + "next": ">=16.3.0 <17", + "pino": "^10.4.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "server-only": "^0.0.1" + }, + "devDependencies": { + "@types/node": "22.18.6", + "@types/react": "^19.1.16", + "@types/react-dom": "^19.1.9", + "eslint": "^9.36.0", + "eslint-config-next": ">=16.3.0 <17", + "typescript": "5.9.3", + "vitest": "4.0.7" + } +} diff --git a/web/service/submitqueue/public/robots.txt b/web/service/submitqueue/public/robots.txt new file mode 100644 index 000000000..eb0536286 --- /dev/null +++ b/web/service/submitqueue/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/web/service/submitqueue/src/app/(protected)/[[...path]]/page.tsx b/web/service/submitqueue/src/app/(protected)/[[...path]]/page.tsx new file mode 100644 index 000000000..f8507cb38 --- /dev/null +++ b/web/service/submitqueue/src/app/(protected)/[[...path]]/page.tsx @@ -0,0 +1,11 @@ +import { createNextPage } from "../../../next/page"; +import { resolveReferenceWeb } from "../../../server/host"; +import { requireAuthorization } from "../../../next/authorize"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +const page = createNextPage({ web: resolveReferenceWeb, authenticate: requireAuthorization }); + +export const generateMetadata = page.generateMetadata; +export default page.Page; diff --git a/web/service/submitqueue/src/app/(protected)/layout.tsx b/web/service/submitqueue/src/app/(protected)/layout.tsx new file mode 100644 index 000000000..279e62935 --- /dev/null +++ b/web/service/submitqueue/src/app/(protected)/layout.tsx @@ -0,0 +1,18 @@ +import type { ReactNode } from "react"; +import { SubmitQueueShell } from "@submitqueue/web-submitqueue"; + +import { requireAuthorization } from "../../next/authorize"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export default async function ProtectedLayout({ + children, +}: Readonly<{ children: ReactNode }>) { + await requireAuthorization(); + + return ( + {children} + ); +} diff --git a/web/service/submitqueue/src/app/healthz/route.ts b/web/service/submitqueue/src/app/healthz/route.ts new file mode 100644 index 000000000..b7a9ee88b --- /dev/null +++ b/web/service/submitqueue/src/app/healthz/route.ts @@ -0,0 +1,16 @@ +import { createNextHealthRoute } from "../../next/health"; +import { loadAuthConfiguration } from "../../server/auth"; +import { loadCursorSecret, loadGatewayConfiguration } from "../../server/config"; +import { pingReferenceGateway } from "../../server/gateway"; +import { resolveReferenceLogger } from "../../server/observability"; + +export const dynamic = "force-dynamic"; + +export const GET = createNextHealthRoute({ + configuration: () => { + loadAuthConfiguration(); + loadCursorSecret(); + loadGatewayConfiguration(); + }, + gateway: pingReferenceGateway, +}, resolveReferenceLogger()); diff --git a/web/service/submitqueue/src/app/layout.tsx b/web/service/submitqueue/src/app/layout.tsx new file mode 100644 index 000000000..2692ede64 --- /dev/null +++ b/web/service/submitqueue/src/app/layout.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; + +import "@submitqueue/web-submitqueue/styles.css"; + +export const metadata: Metadata = { + title: { + default: "SubmitQueue", + template: "%s · SubmitQueue", + }, + description: "Read-only request status for the SubmitQueue demo queue.", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/web/service/submitqueue/src/app/not-found.tsx b/web/service/submitqueue/src/app/not-found.tsx new file mode 100644 index 000000000..6084e1d93 --- /dev/null +++ b/web/service/submitqueue/src/app/not-found.tsx @@ -0,0 +1,5 @@ +import { SubmitQueueStatePage } from "@submitqueue/web-submitqueue"; + +export default function NotFound() { + return ; +} diff --git a/web/service/submitqueue/src/app/unauthorized.tsx b/web/service/submitqueue/src/app/unauthorized.tsx new file mode 100644 index 000000000..ac11c948a --- /dev/null +++ b/web/service/submitqueue/src/app/unauthorized.tsx @@ -0,0 +1,5 @@ +import { SubmitQueueStatePage } from "@submitqueue/web-submitqueue"; + +export default function Unauthorized() { + return ; +} diff --git a/web/service/submitqueue/src/instrumentation.ts b/web/service/submitqueue/src/instrumentation.ts new file mode 100644 index 000000000..f8f82b15b --- /dev/null +++ b/web/service/submitqueue/src/instrumentation.ts @@ -0,0 +1,7 @@ +// Starts the metrics exporter at server startup so it is scrapeable before the first page request. +export async function register(): Promise { + if (process.env.NEXT_RUNTIME === "nodejs") { + const { resolveReferenceMeter } = await import("./server/observability"); + resolveReferenceMeter(); + } +} diff --git a/web/service/submitqueue/src/next/authorize.ts b/web/service/submitqueue/src/next/authorize.ts new file mode 100644 index 000000000..7da5507a5 --- /dev/null +++ b/web/service/submitqueue/src/next/authorize.ts @@ -0,0 +1,18 @@ +import "server-only"; + +import { headers } from "next/headers"; +import { unauthorized } from "next/navigation"; + +import { isAuthorized, loadAuthConfiguration } from "../server/auth"; + +export async function requireAuthorization(): Promise { + const requestHeaders = await headers(); + if ( + !isAuthorized( + requestHeaders.get("authorization"), + loadAuthConfiguration(), + ) + ) { + unauthorized(); + } +} diff --git a/web/service/submitqueue/src/next/health.test.ts b/web/service/submitqueue/src/next/health.test.ts new file mode 100644 index 000000000..b85c96d6a --- /dev/null +++ b/web/service/submitqueue/src/next/health.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { Logger } from "pino"; +import { createNextHealthRoute } from "./health"; + +function recordingLogger() { + const error = vi.fn(); + const logger = { child: () => logger, debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error } as unknown as Logger; + return { logger, error }; +} + +describe("createNextHealthRoute", () => { + it("is ready and uncached when every check passes", async () => { + const { logger } = recordingLogger(); + const response = await createNextHealthRoute({ configuration: () => {}, gateway: async () => {} }, logger)(); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + await expect(response.json()).resolves.toEqual({ status: "ok" }); + }); + + it("stops at the first failing check and logs it once until it recovers", async () => { + const cause = new Error("connection refused"); + const gateway = vi.fn().mockRejectedValueOnce(cause).mockRejectedValueOnce(cause) + .mockResolvedValueOnce(undefined).mockRejectedValueOnce(cause); + const later = vi.fn(); + const { logger, error } = recordingLogger(); + const GET = createNextHealthRoute({ gateway, later }, logger); + + expect((await GET()).status).toBe(503); + expect((await GET()).status).toBe(503); + expect(error).toHaveBeenCalledOnce(); + expect(error).toHaveBeenCalledWith({ check: "gateway", err: cause }, "Web readiness check failed"); + expect(later).not.toHaveBeenCalled(); + expect((await GET()).status).toBe(200); + expect((await GET()).status).toBe(503); + expect(error).toHaveBeenCalledTimes(2); + }); +}); diff --git a/web/service/submitqueue/src/next/health.ts b/web/service/submitqueue/src/next/health.ts new file mode 100644 index 000000000..9861c871e --- /dev/null +++ b/web/service/submitqueue/src/next/health.ts @@ -0,0 +1,29 @@ +import type { Logger } from "pino"; + +export type HealthChecks = Readonly void | Promise>>; + +const NO_STORE = { "Cache-Control": "no-store" }; + +/** + * A readiness route handler: runs the named checks in order and answers 200 + * when all pass, otherwise 503. Each check's failure is logged once until it + * recovers, so a polling orchestrator does not flood the logs. + */ +export function createNextHealthRoute(checks: HealthChecks, logger: Logger) { + const failing = new Set(); + return async function GET(): Promise { + for (const [name, check] of Object.entries(checks)) { + try { + await check(); + } catch (cause) { + if (!failing.has(name)) { + failing.add(name); + logger.error({ check: name, err: cause }, "Web readiness check failed"); + } + return Response.json({ status: "unavailable" }, { status: 503, headers: NO_STORE }); + } + failing.delete(name); + } + return Response.json({ status: "ok" }, { headers: NO_STORE }); + }; +} diff --git a/web/service/submitqueue/src/next/navigation.tsx b/web/service/submitqueue/src/next/navigation.tsx new file mode 100644 index 000000000..3057f61d7 --- /dev/null +++ b/web/service/submitqueue/src/next/navigation.tsx @@ -0,0 +1,57 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef, useTransition, type ReactNode } from "react"; +import { WebNavigationProvider, type WebLinkProps, type WebNavigation } from "@submitqueue/web-submitqueue"; + +// Prefetching dynamic pages would turn every visible link into a backend read. +function NextWebLink(props: WebLinkProps) { + return ; +} + +/** + * Next.js navigation for a `WebApp`. A refresh re-renders the server page + * inside a transition and settles only once that transition finishes, so + * polling never overlaps an in-flight refresh. + */ +export function useNextNavigation(): WebNavigation { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const completion = useRef<(() => void) | null>(null); + const transitionStarted = useRef(false); + + const refresh = useCallback((href: string | null) => new Promise((resolve) => { + completion.current = resolve; + transitionStarted.current = false; + startTransition(() => href ? router.replace(href) : router.refresh()); + }), [router, startTransition]); + + useEffect(() => { + if (!completion.current) { + return; + } + if (pending) { + transitionStarted.current = true; + } else if (transitionStarted.current) { + completion.current(); + completion.current = null; + transitionStarted.current = false; + } + }, [pending]); + + useEffect(() => () => { + completion.current?.(); + completion.current = null; + }, []); + + return useMemo(() => ({ + Link: NextWebLink, + push: (href: string) => router.push(href), + refresh, + }), [refresh, router]); +} + +export function NextNavigationProvider({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/web/service/submitqueue/src/next/page.tsx b/web/service/submitqueue/src/next/page.tsx new file mode 100644 index 000000000..eaf618b66 --- /dev/null +++ b/web/service/submitqueue/src/next/page.tsx @@ -0,0 +1,67 @@ +import type { Metadata } from "next"; +import { forbidden, notFound, redirect } from "next/navigation"; +import { connection } from "next/server"; +import { cache } from "react"; +import { SubmitQueueApp } from "@submitqueue/web-submitqueue"; +import type { SubmitQueueWeb, WebSearchParams } from "@submitqueue/web-submitqueue/server"; +import { NextNavigationProvider } from "./navigation"; + +export interface NextPageProps { + params: Promise<{ path?: string[] }>; + searchParams: Promise; +} + +export interface NextPageOptions { + /** The web UI, or a resolver called per request so configuration is read at runtime rather than build time. */ + web: SubmitQueueWeb | (() => SubmitQueueWeb); + /** + * Authenticates the request and returns the principal passed to the web UI. + * It may call Next's `unauthorized()`; it runs before every load. + */ + authenticate?: () => Promise; +} + +/** + * The default export and `generateMetadata` for an optional catch-all route, + * `app/[[...path]]/page.tsx`. Both share one load per request; the page maps + * results to Next's redirect, not-found, and forbidden responses and renders + * `SubmitQueueApp` with Next navigation. + */ +export function createNextPage(options: NextPageOptions) { + // React's cache keys on primitives, so the request is passed as strings. + const loadPage = cache(async (path: string, encodedSearch: string) => { + await connection(); + const principal = await options.authenticate?.(); + const web = typeof options.web === "function" ? options.web() : options.web; + return web.handle({ + path, search: JSON.parse(encodedSearch) as WebSearchParams, + ...(principal === undefined ? {} : { principal }), + }); + }); + + async function load(props: NextPageProps) { + const [{ path = [] }, search] = await Promise.all([props.params, props.searchParams]); + return loadPage(`/${path.join("/")}`, JSON.stringify(search)); + } + + async function generateMetadata(props: NextPageProps): Promise { + const result = await load(props); + return result.kind === "render" ? { title: result.model.title } : {}; + } + + async function Page(props: NextPageProps) { + const result = await load(props); + switch (result.kind) { + case "not-found": + notFound(); + case "forbidden": + forbidden(); + case "redirect": + redirect(result.href); + case "render": + return ; + } + } + + return { Page, generateMetadata }; +} diff --git a/web/service/submitqueue/src/proxy.test.ts b/web/service/submitqueue/src/proxy.test.ts new file mode 100644 index 000000000..b33ba426f --- /dev/null +++ b/web/service/submitqueue/src/proxy.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { BASIC_AUTH_USERNAME } from "./server/auth"; +import { proxy } from "./proxy"; + +const token = "demo-token-with-at-least-thirty-two-characters"; + +function request(authorization?: string) { + return { + headers: new Headers( + authorization ? { authorization } : undefined, + ), + } as Parameters[0]; +} + +function basic(username: string, password: string): string { + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; +} + +describe("proxy", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("challenges an anonymous browser", () => { + vi.stubEnv("SUBMITQUEUE_WEB_TOKEN", token); + const response = proxy(request()); + + expect(response?.status).toBe(401); + expect(response?.headers.get("www-authenticate")).toMatch(/^Basic /u); + expect(response?.headers.get("cache-control")).toBe("no-store"); + }); + + it("allows valid demo credentials", () => { + vi.stubEnv("SUBMITQUEUE_WEB_TOKEN", token); + expect(proxy(request(basic(BASIC_AUTH_USERNAME, token))).status).toBe(200); + }); + + it("challenges an incorrect password", () => { + vi.stubEnv("SUBMITQUEUE_WEB_TOKEN", token); + expect( + proxy(request(basic(BASIC_AUTH_USERNAME, "incorrect-password-value-000000"))) + ?.status, + ).toBe(401); + }); +}); diff --git a/web/service/submitqueue/src/proxy.ts b/web/service/submitqueue/src/proxy.ts new file mode 100644 index 000000000..f6506fddc --- /dev/null +++ b/web/service/submitqueue/src/proxy.ts @@ -0,0 +1,16 @@ +import { NextResponse, type NextRequest } from "next/server"; + +import { authenticationChallenge, isAuthorized, loadAuthConfiguration } from "./server/auth"; + +// Pages repeat the check themselves; this only challenges anonymous browsers early. +export function proxy(request: NextRequest): Response { + return isAuthorized(request.headers.get("authorization"), loadAuthConfiguration()) + ? NextResponse.next() + : authenticationChallenge(); +} + +export const config = { + matcher: [ + "/((?!healthz$|_next/static|_next/image|favicon.ico|robots.txt).*)", + ], +}; diff --git a/web/service/submitqueue/src/server/auth.test.ts b/web/service/submitqueue/src/server/auth.test.ts new file mode 100644 index 000000000..c7bfd20a9 --- /dev/null +++ b/web/service/submitqueue/src/server/auth.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { + BASIC_AUTH_USERNAME, + isAuthorized, + loadAuthConfiguration, +} from "./auth"; + +const token = "test"; + +function basic(username: string, password: string): string { + return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; +} + +describe("loadAuthConfiguration", () => { + it("loads the local demo password", () => { + expect(loadAuthConfiguration({ SUBMITQUEUE_WEB_TOKEN: token })).toEqual({ + token, + }); + }); + + it.each([undefined, ""])("rejects a missing or empty token", (value) => { + expect(() => + loadAuthConfiguration({ SUBMITQUEUE_WEB_TOKEN: value }), + ).toThrow(Error); + }); +}); + +describe("isAuthorized", () => { + it("accepts the configured credentials", () => { + expect( + isAuthorized(basic("test", "test"), { token }), + ).toBe(true); + }); + + it.each([ + ["missing header", null], + ["wrong scheme", `Bearer ${token}`], + ["malformed base64", "Basic !!!"], + ["missing separator", `Basic ${Buffer.from("submitqueue").toString("base64")}`], + ["wrong user", basic("operator", token)], + ["previous demo user", basic("submitqueue", token)], + ["wrong password", basic(BASIC_AUTH_USERNAME, "b".repeat(32))], + ])("rejects %s", (_name, authorization) => { + expect(isAuthorized(authorization, { token })).toBe(false); + }); + + it("allows colons in the password", () => { + const password = `${token}:suffix`; + expect( + isAuthorized(basic(BASIC_AUTH_USERNAME, password), { token: password }), + ).toBe(true); + }); +}); diff --git a/web/service/submitqueue/src/server/auth.ts b/web/service/submitqueue/src/server/auth.ts new file mode 100644 index 000000000..02bbf68c8 --- /dev/null +++ b/web/service/submitqueue/src/server/auth.ts @@ -0,0 +1,83 @@ +import "server-only"; + +import { createHash, timingSafeEqual } from "node:crypto"; + +export const BASIC_AUTH_USERNAME = "test"; +export const BASIC_AUTH_REALM = "SubmitQueue demo"; + +export type AuthConfiguration = Readonly<{ + token: string; +}>; + +export function loadAuthConfiguration( + environment: Readonly> = process.env, +): AuthConfiguration { + const token = environment.SUBMITQUEUE_WEB_TOKEN ?? ""; + if (token.length === 0) { + throw new Error("SUBMITQUEUE_WEB_TOKEN must be set"); + } + return { token }; +} + +function decodeBasicCredentials( + authorization: string | null, +): Readonly<{ username: string; password: string }> | undefined { + if (authorization === null) { + return undefined; + } + + const [scheme, encoded, ...extra] = authorization.trim().split(/\s+/u); + if (scheme?.toLowerCase() !== "basic" || !encoded || extra.length !== 0) { + return undefined; + } + if ( + !/^[A-Za-z0-9+/]+={0,2}$/u.test(encoded) || + encoded.length % 4 === 1 + ) { + return undefined; + } + + const decoded = Buffer.from(encoded, "base64").toString("utf8"); + + const separator = decoded.indexOf(":"); + if (separator < 0) { + return undefined; + } + return { + username: decoded.slice(0, separator), + password: decoded.slice(separator + 1), + }; +} + +function digest(value: string): Buffer { + return createHash("sha256").update(value, "utf8").digest(); +} + +function securelyEqual(actual: string, expected: string): boolean { + return timingSafeEqual(digest(actual), digest(expected)); +} + +export function isAuthorized( + authorization: string | null, + configuration: AuthConfiguration, +): boolean { + const credentials = decodeBasicCredentials(authorization); + if (!credentials) { + return false; + } + return ( + securelyEqual(credentials.username, BASIC_AUTH_USERNAME) && + securelyEqual(credentials.password, configuration.token) + ); +} + +export function authenticationChallenge(): Response { + return new Response("Authentication required", { + status: 401, + headers: { + "Cache-Control": "no-store", + "Content-Type": "text/plain; charset=utf-8", + "WWW-Authenticate": `Basic realm="${BASIC_AUTH_REALM}", charset="UTF-8"`, + }, + }); +} diff --git a/web/service/submitqueue/src/server/config.test.ts b/web/service/submitqueue/src/server/config.test.ts new file mode 100644 index 000000000..ca7be7c03 --- /dev/null +++ b/web/service/submitqueue/src/server/config.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import { loadCursorSecret, loadGatewayConfiguration, loadMetricsPort } from "./config"; + +describe("loadGatewayConfiguration", () => { + it("uses TLS by default", () => { + expect( + loadGatewayConfiguration({ + SUBMITQUEUE_GATEWAY_URL: "https://gateway.example:8081", + }), + ).toEqual({ + baseUrl: "https://gateway.example:8081", + allowPlaintext: false, + }); + }); + + it("allows h2c only when explicitly enabled", () => { + expect( + loadGatewayConfiguration({ + SUBMITQUEUE_GATEWAY_ALLOW_PLAINTEXT: "true", + SUBMITQUEUE_GATEWAY_URL: "http://gateway-service:8081", + }), + ).toEqual({ + baseUrl: "http://gateway-service:8081", + allowPlaintext: true, + }); + }); + + it.each([ + ["missing URL", {}], + ["relative URL", { SUBMITQUEUE_GATEWAY_URL: "gateway:8081" }], + ["implicit plaintext", { SUBMITQUEUE_GATEWAY_URL: "http://gateway:8081" }], + ["unsupported scheme", { SUBMITQUEUE_GATEWAY_URL: "ftp://gateway:8081" }], + ["path", { SUBMITQUEUE_GATEWAY_URL: "https://gateway:8081/grpc" }], + ["query", { SUBMITQUEUE_GATEWAY_URL: "https://gateway:8081?x=1" }], + ["credentials", { SUBMITQUEUE_GATEWAY_URL: "https://user:pass@gateway:8081" }], + ])("rejects %s", (_name, environment) => { + expect(() => loadGatewayConfiguration(environment)).toThrow(); + }); +}); + +describe("loadCursorSecret", () => { + it("requires its own secret", () => { + expect(loadCursorSecret({ SUBMITQUEUE_WEB_CURSOR_SECRET: "cursor-secret" })).toBe("cursor-secret"); + expect(() => loadCursorSecret({ SUBMITQUEUE_WEB_TOKEN: "viewer-password" })).toThrow(); + }); +}); + +describe("loadMetricsPort", () => { + it.each([ + ["unset", {}, null], + ["a port", { SUBMITQUEUE_WEB_METRICS_PORT: "9464" }, 9464], + ])("reads %s", (_name, environment, port) => { + expect(loadMetricsPort(environment)).toBe(port); + }); + + it.each(["0", "65536", "http"])("rejects %s", value => { + expect(() => loadMetricsPort({ SUBMITQUEUE_WEB_METRICS_PORT: value })).toThrow(); + }); +}); diff --git a/web/service/submitqueue/src/server/config.ts b/web/service/submitqueue/src/server/config.ts new file mode 100644 index 000000000..dd7089fb1 --- /dev/null +++ b/web/service/submitqueue/src/server/config.ts @@ -0,0 +1,44 @@ +import "server-only"; + +import { parseGatewayBaseUrl } from "./transport"; + +export const GATEWAY_DEADLINE_MS = 5_000; +export const GATEWAY_HEALTH_DEADLINE_MS = 1_000; + +type Environment = Readonly>; + +export type GatewayConfiguration = Readonly<{ + baseUrl: string; + allowPlaintext: boolean; +}>; + +export function loadGatewayConfiguration(environment: Environment = process.env): GatewayConfiguration { + const rawBaseUrl = environment.SUBMITQUEUE_GATEWAY_URL; + if (!rawBaseUrl) { + throw new Error("SUBMITQUEUE_GATEWAY_URL is required"); + } + const allowPlaintext = environment.SUBMITQUEUE_GATEWAY_ALLOW_PLAINTEXT === "true"; + return { baseUrl: parseGatewayBaseUrl(rawBaseUrl, allowPlaintext), allowPlaintext }; +} + +/** The key that signs pagination cursors; independent of any viewer credential so either can rotate alone. */ +export function loadCursorSecret(environment: Environment = process.env): string { + const secret = environment.SUBMITQUEUE_WEB_CURSOR_SECRET ?? ""; + if (secret.length === 0) { + throw new Error("SUBMITQUEUE_WEB_CURSOR_SECRET must be set"); + } + return secret; +} + +/** Port for the Prometheus metrics endpoint, or `null` when metrics export is disabled. */ +export function loadMetricsPort(environment: Environment = process.env): number | null { + const raw = environment.SUBMITQUEUE_WEB_METRICS_PORT; + if (!raw) { + return null; + } + const port = Number(raw); + if (!Number.isInteger(port) || port <= 0 || port > 65_535) { + throw new Error("SUBMITQUEUE_WEB_METRICS_PORT must be a TCP port"); + } + return port; +} diff --git a/web/service/submitqueue/src/server/gateway.ts b/web/service/submitqueue/src/server/gateway.ts new file mode 100644 index 000000000..8c691d730 --- /dev/null +++ b/web/service/submitqueue/src/server/gateway.ts @@ -0,0 +1,24 @@ +import "server-only"; + +import { createClient, type Client } from "@connectrpc/connect"; +import { SubmitQueueGateway } from "@submitqueue/api/submitqueue/gateway"; +import { newConnectTransport } from "./transport"; +import { GATEWAY_DEADLINE_MS, GATEWAY_HEALTH_DEADLINE_MS, loadGatewayConfiguration } from "./config"; + +let client: Client | undefined; + +/** The process-wide gateway client; the demo's visibility does not depend on the viewer. */ +export function resolveReferenceGateway(): Client { + client ??= createClient(SubmitQueueGateway, newConnectTransport({ + ...loadGatewayConfiguration(), + defaultTimeoutMs: GATEWAY_DEADLINE_MS, + })); + return client; +} + +export async function pingReferenceGateway(): Promise { + await resolveReferenceGateway().ping( + { message: "submitqueue-web-readiness" }, + { timeoutMs: GATEWAY_HEALTH_DEADLINE_MS }, + ); +} diff --git a/web/service/submitqueue/src/server/host.ts b/web/service/submitqueue/src/server/host.ts new file mode 100644 index 000000000..5c1314ca8 --- /dev/null +++ b/web/service/submitqueue/src/server/host.ts @@ -0,0 +1,24 @@ +import "server-only"; + +import { newHmacCursorCodec } from "@submitqueue/web-submitqueue/extension/cursor/hmac"; +import { createSubmitQueueWeb, type SubmitQueueWeb } from "@submitqueue/web-submitqueue/server"; +import { loadCursorSecret } from "./config"; +import { resolveReferenceGateway } from "./gateway"; +import { resolveReferenceLogger, resolveReferenceMeter } from "./observability"; + +let web: SubmitQueueWeb | undefined; + +/** + * The OSS reference composition: SubmitQueue mounted at the root, read + * through the Connect gateway client, with pino logs and opt-in Prometheus + * metrics. Frameworks adapt this composition; they do not build their own. + */ +export function resolveReferenceWeb(): SubmitQueueWeb { + web ??= createSubmitQueueWeb({ + gateway: resolveReferenceGateway, + cursors: newHmacCursorCodec(loadCursorSecret()), + logger: resolveReferenceLogger(), + meter: resolveReferenceMeter(), + }); + return web; +} diff --git a/web/service/submitqueue/src/server/observability.ts b/web/service/submitqueue/src/server/observability.ts new file mode 100644 index 000000000..0cfae1b08 --- /dev/null +++ b/web/service/submitqueue/src/server/observability.ts @@ -0,0 +1,34 @@ +import "server-only"; + +import { metrics, type Meter } from "@opentelemetry/api"; +import { PrometheusExporter } from "@opentelemetry/exporter-prometheus"; +import { MeterProvider } from "@opentelemetry/sdk-metrics"; +import { pino, type Logger } from "pino"; +import { loadMetricsPort } from "./config"; + +let logger: Logger | undefined; + +/** The process-wide reference logger: JSON lines on stdout at `LOG_LEVEL` (default `info`). */ +export function resolveReferenceLogger(): Logger { + logger ??= pino({ name: "submitqueue-web", level: process.env.LOG_LEVEL ?? "info" }); + return logger; +} + +let meter: Meter | undefined; + +/** + * The process-wide reference meter. The first call registers a + * Prometheus-exporting provider when `SUBMITQUEUE_WEB_METRICS_PORT` is set; + * otherwise metrics stay on the OpenTelemetry API's no-op default. + */ +export function resolveReferenceMeter(): Meter { + if (meter) { + return meter; + } + const port = loadMetricsPort(); + if (port !== null) { + metrics.setGlobalMeterProvider(new MeterProvider({ readers: [new PrometheusExporter({ port })] })); + } + meter = metrics.getMeter("submitqueue-web"); + return meter; +} diff --git a/web/service/submitqueue/src/server/transport.test.ts b/web/service/submitqueue/src/server/transport.test.ts new file mode 100644 index 000000000..c094ecb6b --- /dev/null +++ b/web/service/submitqueue/src/server/transport.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { parseGatewayBaseUrl } from "./transport"; + +describe("parseGatewayBaseUrl", () => { + it.each([ + ["an https origin", "https://gateway.example:8443/", false, "https://gateway.example:8443"], + ["an allowed plaintext origin", "http://gateway:8081", true, "http://gateway:8081"], + ])("accepts %s", (_name, raw, allowPlaintext, origin) => { + expect(parseGatewayBaseUrl(raw, allowPlaintext)).toBe(origin); + }); + + it.each([ + ["a relative URL", "gateway:8081"], + ["a path", "https://gateway.example/api"], + ["credentials", "https://user:secret@gateway.example"], + ["plaintext without opting in", "http://gateway:8081"], + ["another protocol", "ftp://gateway.example"], + ])("rejects %s", (_name, raw) => { + expect(() => parseGatewayBaseUrl(raw)).toThrow(); + }); +}); diff --git a/web/service/submitqueue/src/server/transport.ts b/web/service/submitqueue/src/server/transport.ts new file mode 100644 index 000000000..19689c576 --- /dev/null +++ b/web/service/submitqueue/src/server/transport.ts @@ -0,0 +1,47 @@ +import "server-only"; + +import type { Interceptor, Transport } from "@connectrpc/connect"; +import { createGrpcTransport } from "@connectrpc/connect-node"; + +export interface ConnectTransportConfig { + /** Gateway origin, `https://host[:port]`; `http://` only with `allowPlaintext`. */ + baseUrl: string; + /** Permits plaintext HTTP/2 (h2c), for local stacks only. */ + allowPlaintext?: boolean; + /** Deadline applied to calls that set none. */ + defaultTimeoutMs?: number; + interceptors?: readonly Interceptor[]; +} + +/** + * Validates a gateway URL and returns its origin. Throws on invalid + * configuration, which is a startup failure rather than a per-request outcome. + */ +export function parseGatewayBaseUrl(raw: string, allowPlaintext = false): string { + if (!URL.canParse(raw)) { + throw new Error("The gateway URL must be a valid absolute URL"); + } + const url = new URL(raw); + if (url.pathname !== "/" || url.search || url.hash) { + throw new Error("The gateway URL must not contain a path, query, or fragment"); + } + if (url.username || url.password) { + throw new Error("The gateway URL must not contain credentials"); + } + if (url.protocol === "http:" && !allowPlaintext) { + throw new Error("Plaintext gateway connections must be explicitly allowed"); + } + if (url.protocol !== "https:" && url.protocol !== "http:") { + throw new Error("The gateway URL must use https, or explicitly allowed http"); + } + return url.origin; +} + +/** A gRPC transport for Node; TLS unless plaintext is explicitly allowed. */ +export function newConnectTransport(config: ConnectTransportConfig): Transport { + return createGrpcTransport({ + baseUrl: parseGatewayBaseUrl(config.baseUrl, config.allowPlaintext), + interceptors: [...(config.interceptors ?? [])], + ...(config.defaultTimeoutMs === undefined ? {} : { defaultTimeoutMs: config.defaultTimeoutMs }), + }); +} diff --git a/web/service/submitqueue/src/test/server-only.ts b/web/service/submitqueue/src/test/server-only.ts new file mode 100644 index 000000000..cb0ff5c3b --- /dev/null +++ b/web/service/submitqueue/src/test/server-only.ts @@ -0,0 +1 @@ +export {}; diff --git a/web/service/submitqueue/tsconfig.json b/web/service/submitqueue/tsconfig.json new file mode 100644 index 000000000..2523b07a6 --- /dev/null +++ b/web/service/submitqueue/tsconfig.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "allowJs": false, + "esModuleInterop": true, + "incremental": true, + "isolatedModules": true, + "jsx": "react-jsx", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "module": "esnext", + "moduleResolution": "bundler", + "noEmit": true, + "plugins": [ + { + "name": "next" + } + ], + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022" + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/web/service/submitqueue/vitest.config.ts b/web/service/submitqueue/vitest.config.ts new file mode 100644 index 000000000..35ed019bb --- /dev/null +++ b/web/service/submitqueue/vitest.config.ts @@ -0,0 +1,18 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "server-only": path.resolve(import.meta.dirname, "src/test/server-only.ts"), + }, + }, + test: { + environment: "node", + server: { + deps: { + inline: ["@submitqueue/web-submitqueue"], + }, + }, + }, +}); diff --git a/web/submitqueue/.gitignore b/web/submitqueue/.gitignore new file mode 100644 index 000000000..1eae0cf67 --- /dev/null +++ b/web/submitqueue/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/web/submitqueue/BUILD.bazel b/web/submitqueue/BUILD.bazel new file mode 100644 index 000000000..5045b0905 --- /dev/null +++ b/web/submitqueue/BUILD.bazel @@ -0,0 +1,118 @@ +load("@aspect_rules_js//npm:defs.bzl", "npm_package") +load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project") +load("@npm//:defs.bzl", "npm_link_all_packages") +load("@npm//web/submitqueue:vitest/package_json.bzl", vitest = "bin") + +package(default_visibility = ["//web:__subpackages__"]) + +npm_link_all_packages(name = "node_modules") + +_SRCS = glob( + [ + "**/*.ts", + "**/*.tsx", + ], + exclude = [ + "**/*.test.ts", + "**/*.test.tsx", + "dist/**", + "node_modules/**", + "test-setup.ts", + "vitest.config.ts", + ], +) + +_TESTS = glob([ + "**/*.test.ts", + "**/*.test.tsx", +]) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", +) + +ts_config( + name = "tsconfig_build", + src = "tsconfig.build.json", + deps = [":tsconfig"], +) + +ts_project( + name = "lib", + srcs = _SRCS, + declaration = True, + declaration_map = True, + no_emit = False, + out_dir = "dist", + root_dir = ".", + transpiler = "tsc", + tsconfig = ":tsconfig_build", + deps = [ + ":node_modules/@opentelemetry/api", + ":node_modules/@types/node", + ":node_modules/@types/react", + ":node_modules/pino", + ":node_modules/react", + ], +) + +ts_project( + name = "typecheck", + srcs = _SRCS + _TESTS + [ + "test-setup.ts", + "vitest.config.ts", + ], + declaration = True, + no_emit = True, + tsconfig = ":tsconfig", + deps = [ + ":node_modules/@connectrpc/connect", + ":node_modules/@opentelemetry/api", + ":node_modules/@testing-library/jest-dom", + ":node_modules/@testing-library/react", + ":node_modules/@types/node", + ":node_modules/@types/react", + ":node_modules/@types/react-dom", + ":node_modules/jsdom", + ":node_modules/pino", + ":node_modules/react", + ":node_modules/react-dom", + ":node_modules/vitest", + ], +) + +npm_package( + name = "pkg", + package = "@submitqueue/web-submitqueue", + packable = True, + srcs = [ + ":lib", + ":lib_types", + "view/styles.css", + "README.md", + "package.json", + ], +) + +vitest.vitest_test( + name = "test", + args = [ + "run", + "--config", + "vitest.config.ts", + ], + chdir = package_name(), + data = _SRCS + _TESTS + [ + ":node_modules", + ":node_modules/vitest", + "//web:node_modules/@opentelemetry/api", + "//web:node_modules/pino", + "//web:node_modules/react", + "//web:node_modules/react-dom", + # jest-dom resolves its vitest peer from the workspace root. + "//web:node_modules/vitest", + "test-setup.ts", + "vitest.config.ts", + ], +) diff --git a/web/submitqueue/README.md b/web/submitqueue/README.md new file mode 100644 index 000000000..a87b14483 --- /dev/null +++ b/web/submitqueue/README.md @@ -0,0 +1,23 @@ +# @submitqueue/web-submitqueue + +The complete read-only SubmitQueue UI as a mountable module. Folders follow the Go domain layout, plus `view/` because Go has no UI layer. + +| Folder | Holds | +|---|---| +| [`entity/`](entity/) | Serializable view models (requests, history, queues, changes, pages) and load results | +| [`core/`](core/) | Paths and `parseRoute`, change references, status table, receipt windows, observability helpers | +| [`extension/gateway/`](extension/gateway/) | The structural gateway contract, the gRPC-status classifier, and test fakes in `mock/` | +| [`extension/cursor/`](extension/cursor/) | The page-cursor contract and its HMAC implementation in `hmac/` | +| [`controller/`](controller/) | One loader per page: directory, queue, request, change | +| [`view/`](view/) | `SubmitQueueApp`, pages, the shell and small components, navigation, polling, and the stylesheet | +| [`module.ts`](module.ts) | `createSubmitQueueWeb`, which serves one page request end to end | + +Entry points: the root is client-safe (`SubmitQueueApp`, page frames, `WebNavigationProvider`, model types); `./server` has `createSubmitQueueWeb`, the gateway and cursor contracts, and `WebPaths`; `./extension/cursor/hmac` signs cursors; `./extension/gateway/mock` has test fakes; `./styles.css` is the full stylesheet. Controllers and helpers stay internal. + +**Hosting it.** Give `createSubmitQueueWeb` a gateway client (any client matching the structural contract, so the module ships no generated code), a `CursorCodec`, and optionally a mount path, authorization check, logger, meter, tracer, error classifier, or cached queue catalog. Route every page path to `handle`, render `SubmitQueueApp` with each page model, and wrap it in a `WebNavigationProvider` for client-side navigation. Hosts must load `./styles.css`, either by importing it or by serving it and linking to it; theme with the `--sq-*` properties. + +**Behavior worth knowing.** Expected outcomes (missing resources, stale cursors) are results, never exceptions. Raw gateway errors are logged, never rendered. Views keep the last good data on transient failures. Request IDs stay opaque in URLs; dot-only segments get a visible `~` escape. + +```bash +./tool/bazel test //web/submitqueue:all +``` diff --git a/web/submitqueue/controller/change.test.ts b/web/submitqueue/controller/change.test.ts new file mode 100644 index 000000000..5920cca76 --- /dev/null +++ b/web/submitqueue/controller/change.test.ts @@ -0,0 +1,81 @@ +import type { SubmitQueueGateway } from "../extension/gateway/index.js"; +import { describe, expect, it, vi } from "vitest"; +import { parseChangeUri, parseChangePath } from "../core/change.js"; +import { readChangeSubmissions } from "./change.js"; + +const uri = "github://github.com/uber/submitqueue/pull/123/0123456789abcdef0123456789abcdef01234567"; +const request = (sqid: string) => ({ sqid, changeUris: [uri] }); +const window = { fromMs: 100, toMs: 200 }; + +describe("bounded change history reader", () => { + it("loads all queue pages and keeps repeated submissions distinct", async () => { + const list = vi.fn() + .mockResolvedValueOnce({ requests: [request("42")], nextPageToken: "next" }) + .mockResolvedValueOnce({ requests: [request("38")], nextPageToken: "" }); + const client = { list } as unknown as SubmitQueueGateway; + const reference = parseChangePath(["github", "github.com", "uber", "submitqueue", "pull", "123"])!; + const result = await readChangeSubmissions(client, "demo-queue", reference, window); + expect(result.submissions.map(value => value.request.sqid)).toEqual(["42", "38"]); + expect(result.nextPageToken).toBeNull(); + expect(list).toHaveBeenLastCalledWith(expect.objectContaining({ pageToken: "next", receivedAtOrAfterMs: 100n, receivedBeforeMs: 200n })); + }); + + it("uses exact-URI lookup rather than the receipt window for a pinned version", async () => { + const getRequestSummaryByChangeURI = vi.fn().mockResolvedValue({ requests: [request("42")] }); + const list = vi.fn(); + const client = { list, getRequestSummaryByChangeURI } as unknown as SubmitQueueGateway; + await readChangeSubmissions(client, "demo-queue", parseChangeUri(uri)!, window); + expect(getRequestSummaryByChangeURI).toHaveBeenCalledWith({ queue: "demo-queue", changeUri: uri }); + expect(list).not.toHaveBeenCalled(); + }); + + it("finds demo git changes by clean identity while retaining raw file hints in backend data", async () => { + const uri = "git://git.example.com/demo/refs%2Fheads%2Fmain/0123456789abcdef0123456789abcdef01234567?sq-files=demo%2F00%2Ffile.txt"; + const list = vi.fn().mockResolvedValue({ requests: [{ sqid: "42", changeUris: [uri] }], nextPageToken: "" }); + const getRequestSummaryByChangeURI = vi.fn(); + const client = { list, getRequestSummaryByChangeURI } as unknown as SubmitQueueGateway; + const result = await readChangeSubmissions(client, "demo-queue", parseChangeUri(uri)!, window); + expect(result.submissions[0]?.request.sqid).toBe("42"); + expect(result.submissions[0]?.versionHref).not.toContain("?"); + expect(getRequestSummaryByChangeURI).not.toHaveBeenCalled(); + }); + + it.each([true, false])("continues a bounded scan without losing older matches (first page has matches: %s)", async (hasMatches) => { + const list = vi.fn().mockImplementation(async ({ pageToken }: { pageToken: string }) => { + const page = pageToken ? Number(pageToken.slice(5)) : 0; + return { + requests: page === 10 ? [request("38")] : page === 0 && hasMatches ? [request("42")] : [], + nextPageToken: page < 10 ? `page-${page + 1}` : "", + }; + }); + const client = { list } as unknown as SubmitQueueGateway; + const reference = parseChangePath(["github", "github.com", "uber", "submitqueue", "pull", "123"])!; + const first = await readChangeSubmissions(client, "demo-queue", reference, window); + expect(first.submissions.map(value => value.request.sqid)).toEqual(hasMatches ? ["42"] : []); + expect(first.nextPageToken).toBe("page-10"); + expect(list).toHaveBeenCalledTimes(10); + const second = await readChangeSubmissions(client, "demo-queue", reference, { ...window, pageToken: first.nextPageToken! }); + expect(second.submissions.map(value => value.request.sqid)).toEqual(["38"]); + expect(second.nextPageToken).toBeNull(); + expect(list).toHaveBeenLastCalledWith(expect.objectContaining({ + pageToken: "page-10", receivedAtOrAfterMs: 100n, receivedBeforeMs: 200n, + })); + }); + + it("rejects a cursor that loops back to the starting page", async () => { + const client = { + list: vi.fn().mockResolvedValue({ requests: [], nextPageToken: "start" }), + } as unknown as SubmitQueueGateway; + const reference = parseChangePath(["github", "github.com", "uber", "submitqueue", "pull", "123"])!; + await expect(readChangeSubmissions(client, "demo-queue", reference, { ...window, pageToken: "start" })) + .rejects.toBeInstanceOf(Error); + }); + + it("fails a looping scan instead of presenting duplicate history", async () => { + const client = { + list: vi.fn().mockResolvedValue({ requests: [request("42")], nextPageToken: "repeated" }), + } as unknown as SubmitQueueGateway; + const reference = parseChangePath(["github", "github.com", "uber", "submitqueue", "pull", "123"])!; + await expect(readChangeSubmissions(client, "demo-queue", reference, window)).rejects.toBeInstanceOf(Error); + }); +}); diff --git a/web/submitqueue/controller/change.ts b/web/submitqueue/controller/change.ts new file mode 100644 index 000000000..cd5a44e0c --- /dev/null +++ b/web/submitqueue/controller/change.ts @@ -0,0 +1,163 @@ +import type { WebSearchParams } from "../core/route.js"; +import type { LoadResult } from "../entity/index.js"; +import type { ChangeDetailModel } from "../entity/index.js"; +import type { GatewayChangeReader, GatewayReader, GatewayRequestSummary } from "../extension/gateway/index.js"; +import { changeHref, parseChangePath, parseChangeUri, type ChangeReference } from "../core/change.js"; +import { WebPaths } from "../core/paths.js"; +import { decodeWindowCursor, defaultRequestWindow, encodeWindowCursor, REQUEST_PAGE_SIZE, type RequestWindow } from "../core/window.js"; +import { INTERNAL_ERROR, INVALID_INPUT } from "./error.js"; +import { callGateway, type GatewayLoadOptions } from "./gateway-call.js"; +import { mapRequestSummary } from "./mapping.js"; +import { notFound, redirect, refreshModel, render, type PageDependencies, type SubmitQueueWebResult } from "./page.js"; + +const MAX_CHANGE_SCAN_PAGES = 10; + +export interface GatewayChangeSubmission { + request: GatewayRequestSummary; + version: string; + versionHref: string; +} + +export interface ChangeSubmissionPage { + submissions: GatewayChangeSubmission[]; + nextPageToken: string | null; +} + +export interface GatewayChangeSubmissionPage { + submissions: readonly GatewayChangeSubmission[]; + pagination?: ChangeDetailModel["pagination"]; +} + +/** + * Finds the submissions of a change: by exact URI when the reference pins a + * reviewable version, otherwise by scanning at most + * {@link MAX_CHANGE_SCAN_PAGES} queue pages of the receipt window. + */ +export async function readChangeSubmissions( + client: GatewayReader & GatewayChangeReader, + queue: string, + reference: ChangeReference, + window: RequestWindow, + paths = new WebPaths(), +): Promise { + const submissions: GatewayChangeSubmission[] = []; + const appendSubmissions = (requests: readonly GatewayRequestSummary[]) => { + for (const request of requests) { + const match = request.changeUris.map(parseChangeUri).find(value => + value !== null && value.logicalPath === reference.logicalPath && + (reference.version === null || value.version === reference.version)); + if (match?.version) { + submissions.push({ + request, version: match.version, + versionHref: changeHref(queue, match, true, paths), + }); + } + } + }; + if (reference.uri !== null && reference.scheme !== "git") { + const response = await client.getRequestSummaryByChangeURI({ queue, changeUri: reference.uri }); + appendSubmissions(response.requests); + return { submissions, nextPageToken: null }; + } + let pageToken = window.pageToken ?? ""; + const seenTokens = new Set(pageToken ? [pageToken] : []); + for (let page = 0; page < MAX_CHANGE_SCAN_PAGES; page += 1) { + const response = await client.list({ + queue, + receivedAtOrAfterMs: BigInt(window.fromMs), + receivedBeforeMs: BigInt(window.toMs), + pageSize: REQUEST_PAGE_SIZE, + pageToken, + }); + appendSubmissions(response.requests); + if (!response.nextPageToken) { + return { submissions, nextPageToken: null }; + } + if (seenTokens.has(response.nextPageToken)) { + // A gateway contract violation; continuing would present duplicate history. + throw new Error("The gateway repeated a queue page token"); + } + pageToken = response.nextPageToken; + seenTokens.add(pageToken); + } + return { submissions, nextPageToken: pageToken }; +} + +export async function loadChangeSubmissions( + read: () => Promise, + context: Omit, + options: GatewayLoadOptions = {}, +): Promise> { + if (!context.queue) { + return { ok: false, error: INVALID_INPUT }; + } + const call = await callGateway(options, "change", { queue: context.queue }, read); + if (!call.ok) { + return call; + } + const response = call.value; + const page: GatewayChangeSubmissionPage = "submissions" in response ? response : { submissions: response }; + const submissions: ChangeDetailModel["submissions"] = []; + for (const item of page.submissions) { + const request = mapRequestSummary(item.request); + if (!request) { + return { ok: false, error: INTERNAL_ERROR }; + } + submissions.push({ request, version: item.version, versionHref: item.versionHref }); + } + return { ok: true, data: { ...context, submissions, ...(page.pagination ? { pagination: page.pagination } : {}) } }; +} + +const PROVIDER_NAMES: Readonly> = { + github: "GitHub", + phab: "Phabricator", + git: "Git", +}; + +export async function loadChangePage( + deps: PageDependencies, + queue: string, + referencePath: readonly string[], + search: WebSearchParams, +): Promise { + const reference = parseChangePath(referencePath); + if (reference === null) { + return notFound; + } + const latestHref = changeHref(queue, reference, reference.version !== null, deps.paths); + const scansQueue = reference.version === null || reference.scheme === "git"; + if (search.from !== undefined || search.to !== undefined || + (search.page !== undefined && (typeof search.page !== "string" || !scansQueue))) { + return redirect(latestHref); + } + const scope = `submitqueue:change:${JSON.stringify([queue, reference.logicalPath, reference.version])}`; + const pageWindow = typeof search.page === "string" ? await decodeWindowCursor(deps.cursors, scope, search.page) : undefined; + if (search.page !== undefined && pageWindow === undefined) { + return redirect(latestHref); + } + const window = pageWindow ?? defaultRequestWindow(deps.now()); + const result = deps.catalog.ok ? await loadChangeSubmissions(async () => { + const page = await readChangeSubmissions(deps.gateway, queue, reference, window, deps.paths); + return { + submissions: page.submissions, + pagination: page.nextPageToken || pageWindow ? { + nextHref: page.nextPageToken + ? `${latestHref}?page=${await encodeWindowCursor(deps.cursors, scope, window, page.nextPageToken)}` : null, + latestHref: pageWindow ? latestHref : null, + } : undefined, + }; + }, { + queue, provider: PROVIDER_NAMES[reference.scheme], + host: reference.host, repository: reference.repository, review: reference.review, pinnedVersion: reference.version, + logicalHref: changeHref(queue, reference, false, deps.paths), + window: scansQueue ? { fromMs: window.fromMs, toMs: window.toMs } : null, + }, deps.gatewayOptions) : deps.catalog; + const refresh = refreshModel(result, pageWindow ? latestHref : null); + return render({ + kind: "change", + key: `${queue}:${reference.logicalPath}:${reference.version ?? "all"}:${typeof search.page === "string" ? search.page : "live"}`, + title: reference.review, basePath: deps.paths.basePath, result, + // Each render of a scanning page reads up to MAX_CHANGE_SCAN_PAGES queue pages, too costly to poll. + refresh: scansQueue ? { ...refresh, terminal: true } : refresh, + }); +} diff --git a/web/submitqueue/controller/controller.test.ts b/web/submitqueue/controller/controller.test.ts new file mode 100644 index 000000000..da0bd4056 --- /dev/null +++ b/web/submitqueue/controller/controller.test.ts @@ -0,0 +1,301 @@ +import { Code, ConnectError } from "@connectrpc/connect"; +import type { Logger } from "pino"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createFakeGatewayReader, gatewayHistoryFixture, gatewayRequestFixture } from "../extension/gateway/mock/index.js"; + +import { loadChangeSubmissions } from "./change.js"; +import { loadQueueDirectory } from "./directory.js"; +import { grpcStatusErrorCode } from "../extension/gateway/grpcstatus/index.js"; +import { webErrorForGatewayCode } from "./error.js"; + +const classifyGatewayError = (error: unknown) => webErrorForGatewayCode(grpcStatusErrorCode(error)); +import { loadRequestList } from "./queue.js"; +import { loadRequestDetail, requestDetailIsComplete, requestDetailRefreshState } from "./request.js"; + +const FAILURE_MESSAGE = "SubmitQueue gateway call failed"; + +function recordingLogger() { + const warn = vi.fn(); + const logger = { child: () => logger, debug: vi.fn(), info: vi.fn(), warn, error: vi.fn() } as unknown as Logger; + return { logger, warn }; +} + +describe("queue discovery mapping", () => { + it("lists gateway-configured queues, including ones without requests", async () => { + const reader = createFakeGatewayReader({ queues: [{ name: "demo-queue" }, { name: "empty-queue" }] }); + const result = await loadQueueDirectory(reader); + expect(result).toEqual({ + ok: true, data: [{ name: "demo-queue", description: "" }, { name: "empty-queue", description: "" }], + }); + }); + + it("returns an empty directory when the gateway has no configured queues", async () => { + expect(await loadQueueDirectory(createFakeGatewayReader({ queues: [] }))).toEqual({ ok: true, data: [] }); + }); + + it("classifies discovery failures without leaking the gateway message", async () => { + const { logger, warn } = recordingLogger(); + const cause = new ConnectError("private upstream detail", Code.Unavailable); + const result = await loadQueueDirectory( + createFakeGatewayReader({ queuesError: cause }), + { logger }, + ); + expect(result).toMatchObject({ ok: false, error: { kind: "transient", retryable: true } }); + expect(JSON.stringify(result)).not.toContain("private upstream detail"); + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ operation: "queues", err: cause }), FAILURE_MESSAGE); + }); + + it.each([{ queues: [{ name: "" }] }, { queues: [{ name: "same" }, { name: "same" }] }])("rejects malformed discovery data", async ({ queues }) => { + const result = await loadQueueDirectory(createFakeGatewayReader({ queues })); + expect(result).toMatchObject({ ok: false, error: { kind: "internal" } }); + }); +}); + +describe("server presentation mapping", () => { + beforeEach(() => vi.clearAllMocks()); + + it.each([false, true])("maps change submissions with optional host pagination (paged: %s)", async paged => { + const request = gatewayRequestFixture(); + const submissions = [{ + request, version: "sha", versionHref: "/change/sha", + }]; + const pagination = { nextHref: "/change?page=next", latestHref: null }; + const result = await loadChangeSubmissions(async () => paged ? { submissions, pagination } : submissions, { + queue: "demo-queue", provider: "Git", host: "git.example.com", + repository: "demo", review: "refs/heads/main", pinnedVersion: null, window: { fromMs: 100, toMs: 200 }, + }); + expect(result).toMatchObject({ + ok: true, data: { submissions: [{ request: { sqid: request.sqid }, version: "sha" }] }, + }); + if (result.ok) { + expect(result.data.pagination).toEqual(paged ? pagination : undefined); + } + }); + + it("maps a request page while preserving its bounds", async () => { + const reader = createFakeGatewayReader({ nextPageToken: "next" }); + const result = await loadRequestList(reader, { + queue: "demo-queue", + receivedAtOrAfterMs: 100, + receivedBeforeMs: 200, + pageToken: "current", + }); + + expect(result).toMatchObject({ + ok: true, + data: { + queue: "demo-queue", + receivedAtOrAfterMs: 100, + receivedBeforeMs: 200, + nextPageToken: "next", + requests: [{ sqid: "demo-queue/1", receivedAtMs: 1_700_000_000_000 }], + }, + }); + }); + + it("leaves list, summary, and history deadlines to the host transport", async () => { + const fake = createFakeGatewayReader(); + const list = vi.fn(fake.list); + const getRequestSummaryByID = vi.fn(fake.getRequestSummaryByID); + const getRequestHistoryByID = vi.fn(fake.getRequestHistoryByID); + const reader = { list, getRequestSummaryByID, getRequestHistoryByID }; + + await loadRequestList(reader, { + queue: "demo-queue", + receivedAtOrAfterMs: 100, + receivedBeforeMs: 200, + }); + await loadRequestDetail(reader, { + queue: "demo-queue", + sqid: "demo-queue/1", + }); + + expect(list.mock.calls[0]).toHaveLength(1); + expect(getRequestSummaryByID.mock.calls[0]).toHaveLength(1); + expect(getRequestHistoryByID.mock.calls[0]).toHaveLength(1); + }); + + it("returns an explicit invalid-input result before calling the gateway", async () => { + const gateway = createFakeGatewayReader(); + const list = vi.spyOn(gateway, "list"); + const result = await loadRequestList(gateway, { + queue: "demo-queue", + receivedAtOrAfterMs: 200, + receivedBeforeMs: 100, + }); + + expect(result).toMatchObject({ ok: false, error: { kind: "user" } }); + expect(list).not.toHaveBeenCalled(); + }); + + it("keeps the summary available when history has a transient failure", async () => { + const { logger, warn } = recordingLogger(); + const historyCause = new ConnectError("private upstream text", Code.Unavailable); + const reader = createFakeGatewayReader({ + summary: gatewayRequestFixture({ status: "landed" }), + historyError: historyCause, + }); + const result = await loadRequestDetail( + reader, + { + queue: "demo-queue", + sqid: "demo-queue/1", + }, + { logger }, + ); + + expect(result).toMatchObject({ + ok: true, + data: { + request: { status: "landed" }, + history: [], + historyError: { kind: "transient", retryable: true }, + }, + }); + expect(warn).toHaveBeenCalledWith({ + operation: "history", + queue: "demo-queue", + sqid: "demo-queue/1", + code: "unavailable", + err: historyCause, + }, FAILURE_MESSAGE); + expect(JSON.stringify(result)).not.toContain("private upstream text"); + if (result.ok) { + expect(requestDetailIsComplete(result.data)).toBe(false); + expect(requestDetailRefreshState(result.data)).toEqual({ + terminal: false, + transientFailureCount: 1, + }); + } + }); + + it("stops a terminal summary when history cannot converge", async () => { + const reader = createFakeGatewayReader({ + summary: gatewayRequestFixture({ status: "landed" }), + historyError: new ConnectError("invalid history", Code.PermissionDenied), + }); + const result = await loadRequestDetail(reader, { + queue: "demo-queue", + sqid: "demo-queue/1", + }); + + expect(result.ok && requestDetailRefreshState(result.data)).toEqual({ + terminal: true, + transientFailureCount: 0, + }); + }); + + it("waits for history to contain the terminal summary status", async () => { + const summary = gatewayRequestFixture({ status: "landed" }); + const incomplete = await loadRequestDetail( + createFakeGatewayReader({ + summary, + history: [gatewayHistoryFixture({ status: "landing" })], + }), + { queue: "demo-queue", sqid: "demo-queue/1" }, + ); + const complete = await loadRequestDetail( + createFakeGatewayReader({ + summary, + history: [gatewayHistoryFixture({ status: "landed" })], + }), + { queue: "demo-queue", sqid: "demo-queue/1" }, + ); + + expect(incomplete.ok && requestDetailIsComplete(incomplete.data)).toBe(false); + expect(complete.ok && requestDetailIsComplete(complete.data)).toBe(true); + }); + + it("maps chronological status and occurrence history", async () => { + const reader = createFakeGatewayReader({ + history: [ + gatewayHistoryFixture({ timestampMs: 1n, status: "started" }), + gatewayHistoryFixture({ + timestampMs: 2n, + type: "event", + status: "", + event: "building", + metadata: { build_url: "https://build.example/1" }, + }), + ], + }); + const result = await loadRequestDetail(reader, { + queue: "demo-queue", + sqid: "demo-queue/1", + }); + + expect(result).toMatchObject({ + ok: true, + data: { + history: [ + { timestampMs: 1, type: "status", status: "started" }, + { timestampMs: 2, type: "event", event: "building" }, + ], + historyError: null, + }, + }); + }); +}); + +describe("gateway error classification", () => { + it.each([ + [Code.InvalidArgument, "user", false], + [Code.NotFound, "not-found", false], + [Code.ResourceExhausted, "user", false], + [Code.Unavailable, "transient", true], + [Code.DeadlineExceeded, "transient", true], + [Code.Internal, "internal", false], + ] as const)("classifies code %s", (code, kind, retryable) => { + const classified = classifyGatewayError(new ConnectError("raw gateway detail", code)); + + expect(classified).toMatchObject({ kind, retryable }); + expect(classified.message).not.toContain("raw gateway detail"); + }); + + it("uses a host classifier for transports without gRPC status codes", async () => { + const { logger, warn } = recordingLogger(); + const result = await loadRequestList( + createFakeGatewayReader({ listError: new Error("custom transport: upstream timed out") }), + { queue: "demo-queue", receivedAtOrAfterMs: 100, receivedBeforeMs: 200 }, + { classifyError: () => "unavailable", logger }, + ); + expect(result).toMatchObject({ ok: false, error: { kind: "transient", retryable: true } }); + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ code: "unavailable" }), FAILURE_MESSAGE); + }); + + it("treats a throwing host classifier as an internal failure", async () => { + const { logger, warn } = recordingLogger(); + const result = await loadRequestList( + createFakeGatewayReader({ listError: new Error("upstream") }), + { queue: "demo-queue", receivedAtOrAfterMs: 100, receivedBeforeMs: 200 }, + { classifyError: () => { throw new Error("classifier bug"); }, logger }, + ); + expect(result).toMatchObject({ ok: false, error: { kind: "internal", retryable: false } }); + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ code: "internal" }), FAILURE_MESSAGE); + }); + + it("does not expose arbitrary errors", () => { + expect(classifyGatewayError(new Error("database password"))).toMatchObject({ + kind: "internal", + retryable: false, + }); + }); + + it("logs one structured failure without exposing the cause", async () => { + const cause = new ConnectError("private upstream detail", Code.Unavailable); + const { logger, warn } = recordingLogger(); + const result = await loadRequestList( + createFakeGatewayReader({ listError: cause }), + { + queue: "demo-queue", + receivedAtOrAfterMs: 100, + receivedBeforeMs: 200, + }, + { logger }, + ); + + expect(warn).toHaveBeenCalledOnce(); + expect(warn).toHaveBeenCalledWith({ operation: "list", queue: "demo-queue", code: "unavailable", err: cause }, FAILURE_MESSAGE); + expect(JSON.stringify(result)).not.toContain("private upstream detail"); + }); +}); diff --git a/web/submitqueue/controller/directory.ts b/web/submitqueue/controller/directory.ts new file mode 100644 index 000000000..443722d99 --- /dev/null +++ b/web/submitqueue/controller/directory.ts @@ -0,0 +1,34 @@ +import type { LoadResult } from "../entity/index.js"; +import type { QueueModel } from "../entity/index.js"; +import type { GatewayQueueReader } from "../extension/gateway/index.js"; +import { INTERNAL_ERROR } from "./error.js"; +import { callGateway, type GatewayLoadOptions } from "./gateway-call.js"; +import { refreshModel, render, type PageDependencies, type SubmitQueueWebResult } from "./page.js"; + +export async function loadQueueDirectory( + gateway: GatewayQueueReader, + options: GatewayLoadOptions = {}, +): Promise> { + const call = await callGateway(options, "queues", { queue: "" }, () => gateway.listQueues({})); + if (!call.ok) { + return call; + } + const response = call.value; + const names = new Set(); + const queues: QueueModel[] = []; + for (const queue of response.queues) { + if (!queue.name || names.has(queue.name)) { + return { ok: false, error: INTERNAL_ERROR }; + } + names.add(queue.name); + queues.push({ name: queue.name, description: "" }); + } + return { ok: true, data: queues }; +} + +export function loadDirectoryPage(deps: PageDependencies): SubmitQueueWebResult { + return render({ + kind: "queues", key: "queues", title: "Queues", basePath: deps.paths.basePath, + result: deps.catalog, refresh: refreshModel(deps.catalog), + }); +} diff --git a/web/submitqueue/controller/error.ts b/web/submitqueue/controller/error.ts new file mode 100644 index 000000000..f60e7e60e --- /dev/null +++ b/web/submitqueue/controller/error.ts @@ -0,0 +1,47 @@ +import type { WebError } from "../entity/index.js"; +import type { GatewayErrorCode } from "../extension/gateway/index.js"; + +export const INVALID_INPUT: WebError = { + kind: "user", + title: "Invalid request", + message: "The request parameters are invalid.", + retryable: false, +}; + +export const INTERNAL_ERROR: WebError = { + kind: "internal", + title: "Something went wrong", + message: "SubmitQueue could not load this information.", + retryable: false, +}; + +/** Viewer-safe presentation of a gateway failure; raw gateway messages are never shown. */ +export function webErrorForGatewayCode(code: GatewayErrorCode): WebError { + switch (code) { + case "invalid-argument": + return INVALID_INPUT; + case "not-found": + return { + kind: "not-found", + title: "Request not found", + message: "The requested SubmitQueue request was not found.", + retryable: false, + }; + case "resource-exhausted": + return { + kind: "user", + title: "Request limit reached", + message: "The requested result set is too large. Narrow the time window and try again.", + retryable: false, + }; + case "unavailable": + return { + kind: "transient", + title: "SubmitQueue is unavailable", + message: "SubmitQueue could not be reached. Try again shortly.", + retryable: true, + }; + case "internal": + return INTERNAL_ERROR; + } +} diff --git a/web/submitqueue/controller/gateway-call.ts b/web/submitqueue/controller/gateway-call.ts new file mode 100644 index 000000000..33ab08eff --- /dev/null +++ b/web/submitqueue/controller/gateway-call.ts @@ -0,0 +1,59 @@ +import { SpanStatusCode, type Meter, type Span, type Tracer } from "@opentelemetry/api"; +import type { WebError } from "../entity/index.js"; +import { beginOperation, defaultMeter, defaultTracer, silentLogger } from "../core/observability.js"; +import type { Logger } from "pino"; +import type { GatewayErrorClassifier, GatewayErrorCode } from "../extension/gateway/index.js"; +import { grpcStatusErrorCode } from "../extension/gateway/grpcstatus/index.js"; +import { webErrorForGatewayCode } from "./error.js"; + +export type GatewayOperation = "list" | "summary" | "history" | "change" | "queues"; + +export interface GatewayLoadOptions { + /** Maps transport errors to codes; defaults to {@link grpcStatusErrorCode}. */ + classifyError?: GatewayErrorClassifier; + /** Receives one warning per failed call, with the raw cause. */ + logger?: Logger; + meter?: Meter; + tracer?: Tracer; +} + +export type GatewayCall = { ok: true; value: T } | { ok: false; error: WebError }; + +function classifyGatewayError(classify: GatewayErrorClassifier, cause: unknown): GatewayErrorCode { + try { + return classify(cause); + } catch { + // A faulty host classifier must not break the never-rejects contract below. + return "internal"; + } +} + +/** + * Performs one gateway read inside a span and a `submitqueue_web.gateway` + * duration metric. A failure is classified, logged with its raw cause, and + * returned as a viewer-safe error; this never rejects. + */ +export async function callGateway( + options: GatewayLoadOptions, + operation: GatewayOperation, + target: { queue: string; sqid?: string }, + call: () => Promise, +): Promise> { + const timing = beginOperation(options.meter ?? defaultMeter(), "submitqueue_web.gateway", { operation }); + const tracer = options.tracer ?? defaultTracer(); + return tracer.startActiveSpan(`submitqueue_web.gateway.${operation}`, async (span: Span): Promise> => { + try { + const value = await call(); + timing.end("success"); + return { ok: true, value }; + } catch (cause) { + const code = classifyGatewayError(options.classifyError ?? grpcStatusErrorCode, cause); + timing.end(code); + span.setStatus({ code: SpanStatusCode.ERROR, message: code }); + (options.logger ?? silentLogger).warn({ operation, ...target, code, err: cause }, "SubmitQueue gateway call failed"); + return { ok: false, error: webErrorForGatewayCode(code) }; + } finally { + span.end(); + } + }); +} diff --git a/web/submitqueue/controller/mapping.ts b/web/submitqueue/controller/mapping.ts new file mode 100644 index 000000000..daa30a429 --- /dev/null +++ b/web/submitqueue/controller/mapping.ts @@ -0,0 +1,35 @@ +import { safeInt64ToNumber } from "../core/int64.js"; +import type { HistoryEventModel, RequestSummaryModel } from "../entity/index.js"; +import type { GatewayHistoryEvent, GatewayRequestSummary } from "../extension/gateway/index.js"; + +export function mapRequestSummary(value: GatewayRequestSummary): RequestSummaryModel | null { + const receivedAtMs = safeInt64ToNumber(value.receivedAtMs); + if (receivedAtMs === null) { + return null; + } + return { + sqid: value.sqid, + queue: value.queue, + changeUris: [...value.changeUris], + receivedAtMs, + status: value.status, + lastError: value.lastError === "" ? null : value.lastError, + metadata: { ...value.metadata }, + }; +} + +export function mapHistoryEvent(value: GatewayHistoryEvent): HistoryEventModel | null { + const timestampMs = safeInt64ToNumber(value.timestampMs); + if (timestampMs === null) { + return null; + } + const type = value.type === "status" || value.type === "event" ? value.type : "unknown"; + return { + timestampMs, + type, + status: value.status === "" ? null : value.status, + event: value.event === "" ? null : value.event, + lastError: value.lastError === "" ? null : value.lastError, + metadata: { ...value.metadata }, + }; +} diff --git a/web/submitqueue/controller/page.ts b/web/submitqueue/controller/page.ts new file mode 100644 index 000000000..e34119a3e --- /dev/null +++ b/web/submitqueue/controller/page.ts @@ -0,0 +1,35 @@ +import type { LoadResult, QueueModel, SubmitQueuePageModel, WebRefreshModel } from "../entity/index.js"; +import type { SubmitQueueGateway } from "../extension/gateway/index.js"; +import type { WebPaths } from "../core/paths.js"; +import type { CursorCodec } from "../extension/cursor/index.js"; +import type { GatewayLoadOptions } from "./gateway-call.js"; + +/** Outcome of one page request; only `render` carries a page. */ +export type SubmitQueueWebResult = + | { kind: "render"; model: SubmitQueuePageModel } + | { kind: "redirect"; href: string } + | { kind: "not-found" } + | { kind: "forbidden" }; + +/** Dependencies of one page load, resolved for the current request. */ +export interface PageDependencies { + gateway: SubmitQueueGateway; + cursors: CursorCodec; + paths: WebPaths; + now(): number; + gatewayOptions: GatewayLoadOptions; + /** Queues the viewer may see; a failure is shown instead of the page's own data. */ + catalog: LoadResult; +} + +export function refreshModel(result: LoadResult, refreshHref: string | null = null): WebRefreshModel { + return { + terminal: refreshHref !== null || (!result.ok && !result.error.retryable), + transientFailureCount: !result.ok && result.error.retryable ? 1 : 0, + refreshHref, + }; +} + +export const render = (model: SubmitQueuePageModel): SubmitQueueWebResult => ({ kind: "render", model }); +export const redirect = (href: string): SubmitQueueWebResult => ({ kind: "redirect", href }); +export const notFound: SubmitQueueWebResult = { kind: "not-found" }; diff --git a/web/submitqueue/controller/queue.ts b/web/submitqueue/controller/queue.ts new file mode 100644 index 000000000..3b64bf242 --- /dev/null +++ b/web/submitqueue/controller/queue.ts @@ -0,0 +1,106 @@ +import type { WebSearchParams } from "../core/route.js"; +import type { LoadResult } from "../entity/index.js"; +import type { RequestListModel, RequestSummaryModel } from "../entity/index.js"; +import type { GatewayReader } from "../extension/gateway/index.js"; +import { requestChangeLabels, requestChangeLinks } from "../core/change.js"; +import { decodeWindowCursor, defaultRequestWindow, encodeWindowCursor, REQUEST_PAGE_SIZE } from "../core/window.js"; +import { INTERNAL_ERROR, INVALID_INPUT } from "./error.js"; +import { callGateway, type GatewayLoadOptions } from "./gateway-call.js"; +import { mapRequestSummary } from "./mapping.js"; +import { redirect, refreshModel, render, type PageDependencies, type SubmitQueueWebResult } from "./page.js"; + +export interface LoadRequestListInput { + queue: string; + receivedAtOrAfterMs: number; + receivedBeforeMs: number; + pageSize?: number; + pageToken?: string; +} + +function validListInput(input: LoadRequestListInput): boolean { + const pageSize = input.pageSize ?? REQUEST_PAGE_SIZE; + return ( + input.queue !== "" && + Number.isSafeInteger(input.receivedAtOrAfterMs) && + Number.isSafeInteger(input.receivedBeforeMs) && + input.receivedAtOrAfterMs < input.receivedBeforeMs && + Number.isInteger(pageSize) && + pageSize > 0 + ); +} + +export async function loadRequestList( + gateway: GatewayReader, + input: LoadRequestListInput, + options: GatewayLoadOptions = {}, +): Promise> { + if (!validListInput(input)) { + return { ok: false, error: INVALID_INPUT }; + } + const call = await callGateway(options, "list", { queue: input.queue }, () => gateway.list({ + queue: input.queue, + receivedAtOrAfterMs: BigInt(input.receivedAtOrAfterMs), + receivedBeforeMs: BigInt(input.receivedBeforeMs), + pageSize: input.pageSize ?? REQUEST_PAGE_SIZE, + pageToken: input.pageToken ?? "", + })); + if (!call.ok) { + return call; + } + const response = call.value; + const requests: RequestSummaryModel[] = []; + for (const value of response.requests) { + const request = mapRequestSummary(value); + if (request === null) { + return { ok: false, error: INTERNAL_ERROR }; + } + requests.push(request); + } + return { + ok: true, + data: { + queue: input.queue, + receivedAtOrAfterMs: input.receivedAtOrAfterMs, + receivedBeforeMs: input.receivedBeforeMs, + requests, + nextPageToken: response.nextPageToken === "" ? null : response.nextPageToken, + }, + }; +} + +// Namespaced so a codec shared with other pages or modules never accepts this page's cursors elsewhere. +function queueCursorScope(queue: string): string { + return `submitqueue:queue:${queue}`; +} + +export async function loadQueuePage( + deps: PageDependencies, + queue: string, + search: WebSearchParams, +): Promise { + const latestHref = deps.paths.requests(queue); + if (search.from !== undefined || search.to !== undefined || + (search.page !== undefined && typeof search.page !== "string")) { + return redirect(latestHref); + } + const pageWindow = typeof search.page === "string" ? await decodeWindowCursor(deps.cursors, queueCursorScope(queue), search.page) : undefined; + if (search.page !== undefined && pageWindow === undefined) { + return redirect(latestHref); + } + const window = pageWindow ?? defaultRequestWindow(deps.now()); + const loaded = deps.catalog.ok ? await loadRequestList(deps.gateway, { + queue, receivedAtOrAfterMs: window.fromMs, receivedBeforeMs: window.toMs, + pageSize: REQUEST_PAGE_SIZE, ...(window.pageToken ? { pageToken: window.pageToken } : {}), + }, deps.gatewayOptions) : deps.catalog; + const result = loaded.ok && loaded.data.nextPageToken ? { + ...loaded, + data: { ...loaded.data, nextPageToken: await encodeWindowCursor(deps.cursors, queueCursorScope(queue), window, loaded.data.nextPageToken) }, + } : loaded; + return render({ + kind: "queue", key: `${queue}:${typeof search.page === "string" ? search.page : "live"}`, title: queue, + basePath: deps.paths.basePath, queue, window: { fromMs: window.fromMs, toMs: window.toMs }, + paged: pageWindow !== undefined, latestHref, result, refresh: refreshModel(result, pageWindow ? latestHref : null), + changeLinks: result.ok ? requestChangeLinks(queue, result.data.requests, deps.paths) : {}, + changeLabels: result.ok ? requestChangeLabels(result.data.requests) : {}, + }); +} diff --git a/web/submitqueue/controller/request.ts b/web/submitqueue/controller/request.ts new file mode 100644 index 000000000..cd9897577 --- /dev/null +++ b/web/submitqueue/controller/request.ts @@ -0,0 +1,105 @@ +import type { WebSearchParams } from "../core/route.js"; +import type { LoadResult } from "../entity/index.js"; +import type { HistoryEventModel, RequestDetailModel } from "../entity/index.js"; +import type { GatewayReader } from "../extension/gateway/index.js"; +import { requestChangeLabels, requestChangeLinks } from "../core/change.js"; +import { isTerminalStatus } from "../core/status.js"; +import { INTERNAL_ERROR, INVALID_INPUT } from "./error.js"; +import { callGateway, type GatewayLoadOptions } from "./gateway-call.js"; +import { mapHistoryEvent, mapRequestSummary } from "./mapping.js"; +import { notFound, redirect, refreshModel, render, type PageDependencies, type SubmitQueueWebResult } from "./page.js"; + +export interface LoadRequestDetailInput { + queue: string; + sqid: string; +} + +export function requestDetailIsComplete(model: RequestDetailModel): boolean { + if (model.historyError !== null || !isTerminalStatus(model.request.status)) { + return false; + } + return model.history.some( + (event) => event.type === "status" && event.status === model.request.status, + ); +} + +export interface RequestDetailRefreshState { + terminal: boolean; + transientFailureCount: number; +} + +/** + * Stops polling once a terminal summary is matched by loaded history, or when + * a terminal summary pairs with a non-retryable history error that polling + * cannot fix. + */ +export function requestDetailRefreshState(model: RequestDetailModel): RequestDetailRefreshState { + const historyError = model.historyError; + return { + terminal: + requestDetailIsComplete(model) || + (isTerminalStatus(model.request.status) && historyError !== null && !historyError.retryable), + transientFailureCount: historyError?.retryable ? 1 : 0, + }; +} + +export async function loadRequestDetail( + gateway: GatewayReader, + input: LoadRequestDetailInput, + options: GatewayLoadOptions = {}, +): Promise> { + if (input.queue === "" || input.sqid === "") { + return { ok: false, error: INVALID_INPUT }; + } + const [summaryResult, historyResult] = await Promise.all([ + callGateway(options, "summary", input, () => gateway.getRequestSummaryByID(input)), + callGateway(options, "history", input, () => gateway.getRequestHistoryByID(input)), + ]); + if (!summaryResult.ok) { + return summaryResult; + } + if (summaryResult.value.request === undefined) { + return { ok: false, error: INTERNAL_ERROR }; + } + const request = mapRequestSummary(summaryResult.value.request); + if (request === null) { + return { ok: false, error: INTERNAL_ERROR }; + } + if (!historyResult.ok) { + return { ok: true, data: { request, history: [], historyError: historyResult.error } }; + } + const history: HistoryEventModel[] = []; + for (const value of historyResult.value.events) { + const event = mapHistoryEvent(value); + if (event === null) { + return { ok: true, data: { request, history: [], historyError: INTERNAL_ERROR } }; + } + history.push(event); + } + return { ok: true, data: { request, history, historyError: null } }; +} + +export async function loadRequestPage( + deps: PageDependencies, + queue: string, + sqid: string, + search: WebSearchParams, +): Promise { + const view = search.view === "history" ? "history" : "summary"; + if (search.from !== undefined || search.to !== undefined || search.page !== undefined) { + return redirect(deps.paths.request(queue, sqid, { view })); + } + const result = deps.catalog.ok ? await loadRequestDetail(deps.gateway, { queue, sqid }, deps.gatewayOptions) : deps.catalog; + if (!result.ok && result.error.kind === "not-found") { + return notFound; + } + return render({ + kind: "request", key: `${queue}:${sqid}`, title: view === "history" ? `${sqid} · History` : sqid, + basePath: deps.paths.basePath, view, result, + backHref: deps.paths.requests(queue), summaryHref: deps.paths.request(queue, sqid), + historyHref: deps.paths.request(queue, sqid, { view: "history" }), + changeLinks: result.ok ? requestChangeLinks(queue, [result.data.request], deps.paths) : {}, + changeLabels: result.ok ? requestChangeLabels([result.data.request]) : {}, + refresh: result.ok ? { ...requestDetailRefreshState(result.data), refreshHref: null } : refreshModel(result), + }); +} diff --git a/web/submitqueue/core/change.test.ts b/web/submitqueue/core/change.test.ts new file mode 100644 index 000000000..43db2c8ab --- /dev/null +++ b/web/submitqueue/core/change.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { changeHref, parseChangePath, parseChangeUri, requestChangeLabels, requestChangeLinks } from "./change"; +import type { RequestSummaryModel } from "../entity/index.js"; + +const sha = "0123456789abcdef0123456789abcdef01234567"; + +describe("readable change identity", () => { + it.each([ + [`github://github.com/uber/submitqueue/pull/123/${sha}`, `/demo-queue/change/github/github.com/uber/submitqueue/pull/123/${sha}`], + ["phab://phabricator.example.com/D12345/67890", "/demo-queue/change/phab/phabricator.example.com/D12345/67890"], + [`git://git.example.com/uber/demo/refs%2Fheads%2Fmain/${sha}`, `/demo-queue/change/git/git.example.com/uber/demo/refs%2Fheads%2Fmain/${sha}`], + ])("preserves authority and path from %s", (uri, href) => { + const reference = parseChangeUri(uri); + expect(reference).not.toBeNull(); + expect(changeHref("demo-queue", reference!, true)).toBe(href); + expect(parseChangePath(href.split("/").slice(3).map(decodeURIComponent))?.uri).toBe(uri); + }); + + it("distinguishes logical review identity from its submitted version", () => { + const logical = parseChangePath(["github", "github.com", "uber", "submitqueue", "pull", "123"]); + expect(logical?.uri).toBeNull(); + expect(logical?.version).toBeNull(); + expect(parseChangeUri(`github://github.com/uber/submitqueue/pull/123/${sha}`)?.logicalPath).toBe(logical?.logicalPath); + }); + + it.each([ + ["phab", "other.example", "D01", "2"], + ["github", "github.com", "uber", "..", "pull", "1", sha], + ["github", "GitHub.com", "uber", "repo", "pull", "1", sha], + ["github", "github.com", "uber", "repo", "pull", "1", "abc"], + ])("rejects malformed or noncanonical route %j", (...path) => { + expect(parseChangePath(path)).toBeNull(); + }); + + it.each(["bad%zz", "%FF", "%75ber"])("rejects malformed or noncanonical URI segment %s", (segment) => { + expect(parseChangeUri(`github://github.com/${segment}/submitqueue/pull/123/${sha}`)).toBeNull(); + }); + + it.each([ + [`git://git.example.com/demo/refs%2Fheads%2Fmain/${sha}`, "?sq-files=demo%2F00%2Ffile.txt", "/demo-queue/change/git/git.example.com/demo/refs%2Fheads%2Fmain"], + [`github://github.com/uber/submitqueue/pull/123/${sha}`, "?sq-fake=build-fail&attempt=2", "/demo-queue/change/github/github.com/uber/submitqueue/pull/123"], + ])("hides URI query markers in labels/links without changing the submitted URI: %s", (clean, query, expectedHref) => { + const raw = `${clean}${query}`; + const request: RequestSummaryModel = { + sqid: "42", queue: "demo-queue", changeUris: [raw], receivedAtMs: 1, + status: "landed", lastError: null, metadata: {}, + }; + expect(requestChangeLabels([request])[raw]).toBe(clean); + expect(requestChangeLinks("demo-queue", [request])[raw]).toBe(expectedHref); + expect(request.changeUris).toEqual([raw]); + }); + + it("does not relabel a URI that is already canonical", () => { + const uri = `github://github.com/uber/submitqueue/pull/123/${sha}`; + const request: RequestSummaryModel = { + sqid: "42", queue: "demo-queue", changeUris: [uri], receivedAtMs: 1, + status: "landed", lastError: null, metadata: {}, + }; + expect(requestChangeLabels([request])).toEqual({}); + }); +}); diff --git a/web/submitqueue/core/change.ts b/web/submitqueue/core/change.ts new file mode 100644 index 000000000..294a3eb53 --- /dev/null +++ b/web/submitqueue/core/change.ts @@ -0,0 +1,133 @@ +import type { RequestSummaryModel } from "../entity/index.js"; +import { WebPaths } from "./paths.js"; + +export interface ChangeReference { + scheme: "github" | "phab" | "git"; + host: string; + repository: string; + review: string; + logicalPath: string; + version: string | null; + uri: string | null; +} + +function encodeUriSegment(value: string): string { + return encodeURIComponent(value) + .replace(/[!'()*]/gu, character => `%${character.charCodeAt(0).toString(16).toUpperCase()}`) + .replace(/%(24|26|2B|3A|3D|40)/gu, encoded => String.fromCharCode(Number.parseInt(encoded.slice(1), 16))); +} + +function decodeCanonicalUriSegment(value: string): string | null { + if (Array.from(value).some(character => character.charCodeAt(0) > 127) || + /%(?![a-f0-9]{2})/iu.test(value)) { + return null; + } + const byteString = value.replace(/%([a-f0-9]{2})/giu, (_, hex: string) => + String.fromCharCode(Number.parseInt(hex, 16))); + const decoded = new TextDecoder("utf-8", { ignoreBOM: true }).decode( + Uint8Array.from(byteString, character => character.charCodeAt(0)), + ); + return encodeUriSegment(decoded) === value ? decoded : null; +} + +export function parseChangePath(parts: readonly string[]): ChangeReference | null { + const [scheme, host, ...path] = parts; + if ((scheme !== "github" && scheme !== "phab" && scheme !== "git") || !host || + host !== host.toLowerCase() || !/^[a-z0-9.-]+(?::[0-9]+)?$/u.test(host) || + host === "." || host === ".." || + path.some(value => !value || value === "." || value === ".." || /[?#]/u.test(value) || + Array.from(value).some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127))) { + return null; + } + let repository: string; + let review: string; + let version: string | null; + let logicalParts: string[]; + if (scheme === "git") { + const pinned = /^[a-f0-9]{40}$/u.test(path.at(-1) ?? ""); + const refIndex = path.length - (pinned ? 2 : 1); + const ref = path[refIndex]; + if (refIndex < 1 || !ref?.startsWith("refs/") || ref === "refs/" || + path.slice(0, refIndex).some(value => value.includes("/"))) { + return null; + } + repository = path.slice(0, refIndex).join("/"); + review = ref; + version = pinned ? path.at(-1)! : null; + logicalParts = path.slice(0, refIndex + 1); + } else if (scheme === "github") { + if (path.some(value => value.includes("/"))) { + return null; + } + const separator = path.lastIndexOf("pull"); + if (separator < 2 || (path.length !== separator + 2 && path.length !== separator + 3) || + !/^[1-9][0-9]*$/u.test(path[separator + 1] ?? "")) { + return null; + } + repository = path.slice(0, separator).join("/"); + review = `PR #${path[separator + 1]}`; + version = path[separator + 2] ?? null; + if (version !== null && !/^[a-f0-9]{40}$/u.test(version)) { + return null; + } + logicalParts = path.slice(0, separator + 2); + } else { + if ((path.length !== 1 && path.length !== 2) || !/^D[1-9][0-9]*$/u.test(path[0] ?? "") || + (path[1] !== undefined && !/^[1-9][0-9]*$/u.test(path[1]))) { + return null; + } + repository = ""; + review = path[0]!; + version = path[1] ?? null; + logicalParts = [review]; + } + const logicalPath = [scheme, host, ...logicalParts.map(encodeUriSegment)].join("/"); + const uri = version === null ? null : + `${scheme}://${host}/${[...logicalParts, version].map(encodeUriSegment).join("/")}`; + return { scheme, host, repository, review, logicalPath, version, uri }; +} + +/** Parses a submitted change URI; a query (test markers, for example) is not part of the change's identity and is ignored. */ +export function parseChangeUri(uri: string): ChangeReference | null { + const match = /^(github|phab|git):\/\/([^/?#]+)\/([^?#]+)(?:\?[^#]*)?$/u.exec(uri); + if (!match) { + return null; + } + const path: string[] = []; + for (const segment of match[3]!.split("/")) { + const decoded = decodeCanonicalUriSegment(segment); + if (decoded === null) { + return null; + } + path.push(decoded); + } + const reference = parseChangePath([match[1]!, match[2]!, ...path]); + const canonicalUri = `${match[1]}://${match[2]}/${match[3]}`; + return reference?.uri === canonicalUri ? reference : null; +} + +export function requestChangeLabels(requests: readonly RequestSummaryModel[]): Record { + const labels: Record = {}; + for (const uri of requests.flatMap(request => request.changeUris)) { + const reference = parseChangeUri(uri); + if (reference?.uri && reference.uri !== uri) { + labels[uri] = reference.uri; + } + } + return labels; +} + +export function changeHref(queue: string, reference: ChangeReference, pinned = false, paths = new WebPaths()): string { + return `${paths.requests(queue)}/change/${reference.logicalPath}${pinned && reference.version ? `/${reference.version}` : ""}`; +} + +export function requestChangeLinks(queue: string, requests: readonly RequestSummaryModel[], paths = new WebPaths()): Record { + const links: Record = {}; + for (const uri of requests.flatMap(request => request.changeUris)) { + const reference = parseChangeUri(uri); + if (reference) { + links[uri] = changeHref(queue, reference, false, paths); + } + } + return links; +} diff --git a/web/submitqueue/core/encoding.test.ts b/web/submitqueue/core/encoding.test.ts new file mode 100644 index 000000000..b83bc5ce7 --- /dev/null +++ b/web/submitqueue/core/encoding.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { safeInt64ToNumber } from "./int64.js"; +import { decodePathSegment, encodePathSegment } from "./path-segment.js"; + +describe("safeInt64ToNumber", () => { + it.each([ + ["safe bigint", 1_700_000_000_000n, 1_700_000_000_000], + ["safe decimal string", "1700000000000", 1_700_000_000_000], + ["safe number", 42, 42], + ["Long-style decimal object", { toString: () => "1700000000000" }, 1_700_000_000_000], + ["object without a decimal value", {}, null], + ["bigint beyond the safe range", BigInt(Number.MAX_SAFE_INTEGER) + 1n, null], + ["non-integer number", 1.5, null], + ["non-numeric string", "not-an-integer", null], + ])("converts a %s", (_name, value, expected) => { + expect(safeInt64ToNumber(value)).toBe(expected); + }); +}); + +describe("path segment codec", () => { + it.each([ + ["ordinary value", "demo-queue", "demo-queue"], + ["reserved characters", "a/b?c", "a%2Fb%3Fc"], + ["single dot", ".", "~."], + ["double dot", "..", "~.."], + ["leading tilde", "~value", "~~value"], + ])("round-trips an %s", (_name, value, encoded) => { + expect(encodePathSegment(value)).toBe(encoded); + expect(decodePathSegment(decodeURIComponent(encoded))).toBe(value); + }); +}); diff --git a/web/submitqueue/core/int64.ts b/web/submitqueue/core/int64.ts new file mode 100644 index 000000000..2a4283d9f --- /dev/null +++ b/web/submitqueue/core/int64.ts @@ -0,0 +1,27 @@ +/** + * A protobuf `int64` as produced by common TypeScript protobuf runtimes: + * `bigint` (Protobuf-ES), `number`/`string`, or a `Long`-style object whose + * `toString()` is its decimal value (protobufjs). + */ +export type Int64Value = bigint | number | string | { toString(): string }; + +/** Converts an `int64` to a number, or `null` when it is not a safe integer. */ +export function safeInt64ToNumber(value: Int64Value): number | null { + if (typeof value === "number") { + return Number.isSafeInteger(value) ? value : null; + } + let converted: bigint; + if (typeof value === "bigint") { + converted = value; + } else { + const decimal = typeof value === "string" ? value : value.toString(); + if (!/^-?\d+$/u.test(decimal)) { + return null; + } + converted = BigInt(decimal); + } + if (converted > BigInt(Number.MAX_SAFE_INTEGER) || converted < BigInt(Number.MIN_SAFE_INTEGER)) { + return null; + } + return Number(converted); +} diff --git a/web/submitqueue/core/observability.test.ts b/web/submitqueue/core/observability.test.ts new file mode 100644 index 000000000..05d822c53 --- /dev/null +++ b/web/submitqueue/core/observability.test.ts @@ -0,0 +1,23 @@ +import type { Meter } from "@opentelemetry/api"; +import { describe, expect, it, vi } from "vitest"; + +import { beginOperation, silentLogger } from "./observability.js"; + +describe("beginOperation", () => { + it("records one duration tagged with the start attributes and outcome", () => { + const record = vi.fn(); + const createHistogram = vi.fn(() => ({ record })); + const operation = beginOperation({ createHistogram } as unknown as Meter, "web.page_load", { page: "queue" }); + + operation.end("success", { module: "submitqueue" }); + + expect(createHistogram).toHaveBeenCalledWith("web.page_load.duration", expect.objectContaining({ unit: "ms" })); + expect(record).toHaveBeenCalledWith(expect.any(Number), { page: "queue", module: "submitqueue", outcome: "success" }); + }); +}); + +describe("silentLogger", () => { + it("discards entries, including from child loggers", () => { + expect(silentLogger.child({ requestId: "1" }).isLevelEnabled("fatal")).toBe(false); + }); +}); diff --git a/web/submitqueue/core/observability.ts b/web/submitqueue/core/observability.ts new file mode 100644 index 000000000..00b46847f --- /dev/null +++ b/web/submitqueue/core/observability.ts @@ -0,0 +1,42 @@ +import { metrics, trace, type Attributes, type Meter, type Tracer } from "@opentelemetry/api"; +import { pino, type Logger } from "pino"; + +/** Discards every entry; the default when a host supplies no logger. */ +export const silentLogger: Logger = pino({ level: "silent" }); + +/** Instrumentation scope for meters and tracers the web packages create by default. */ +export const INSTRUMENTATION_SCOPE = "submitqueue-web"; + +/** Latency histogram bucket boundaries in milliseconds, for gateway reads and page loads. */ +export const LATENCY_BUCKETS_MS = [5, 10, 25, 50, 100, 250, 500, 1_000, 2_500, 5_000, 10_000]; + +export function defaultMeter(): Meter { + return metrics.getMeter(INSTRUMENTATION_SCOPE); +} + +export function defaultTracer(): Tracer { + return trace.getTracer(INSTRUMENTATION_SCOPE); +} + +export interface Operation { + /** Records the operation's latency and outcome; call exactly once. */ + end(outcome: string, attributes?: Attributes): void; +} + +/** + * Starts timing one operation. `end` records `.duration` (milliseconds) + * tagged with the start attributes plus `outcome`, the counterpart of the Go + * services' `metrics.Begin`. + */ +export function beginOperation(meter: Meter, name: string, attributes: Attributes = {}): Operation { + const startedAt = performance.now(); + const duration = meter.createHistogram(`${name}.duration`, { + unit: "ms", + advice: { explicitBucketBoundaries: LATENCY_BUCKETS_MS }, + }); + return { + end(outcome, extra = {}) { + duration.record(Math.max(0, performance.now() - startedAt), { ...attributes, ...extra, outcome }); + }, + }; +} diff --git a/web/submitqueue/core/path-segment.ts b/web/submitqueue/core/path-segment.ts new file mode 100644 index 000000000..94d2e0a84 --- /dev/null +++ b/web/submitqueue/core/path-segment.ts @@ -0,0 +1,15 @@ +/** + * Percent-encodes one opaque path segment. Dot-only values and values that + * already start with `~` gain a visible `~` prefix so browsers cannot + * normalize them away; {@link decodePathSegment} reverses it. + */ +export function encodePathSegment(value: string): string { + return value === "." || value === ".." || value.startsWith("~") + ? `~${encodeURIComponent(value)}` + : encodeURIComponent(value); +} + +/** Removes the `~` escape from an already URL-decoded path segment. */ +export function decodePathSegment(value: string): string { + return value.startsWith("~") ? value.slice(1) : value; +} diff --git a/web/submitqueue/core/paths.test.ts b/web/submitqueue/core/paths.test.ts new file mode 100644 index 000000000..b651a6703 --- /dev/null +++ b/web/submitqueue/core/paths.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { decodePathSegment, WebPaths } from "./paths"; + +describe("WebPaths", () => { + it("keeps the queue and complete sqid readable in the request path", () => { + const paths = new WebPaths(); + + expect(paths.request("demo-queue", "demo-queue/42")).toBe( + "/demo-queue/request/demo-queue/42", + ); + }); + + it("uses only the host's opaque cursor while paging", () => { + const paths = new WebPaths({ basePath: "/submitqueue/" }); + const href = paths.requests("demo queue", { + pageToken: "opaque+/=", + }); + + expect(href).toBe( + "/submitqueue/demo%20queue?page=opaque%2B%2F%3D", + ); + }); + + it("URL-escapes reserved characters without interpreting the sqid", () => { + const paths = new WebPaths(); + + expect(paths.request("demo-queue", "opaque/id?query#fragment")).toBe( + "/demo-queue/request/opaque/id%3Fquery%23fragment", + ); + }); + + it.each([".", "..", "~.", "~value"])("round-trips %s without URL dot normalization", (id) => { + const paths = new WebPaths(); + const url = new URL(paths.request(id, `${id}/42`), "https://example.test"); + const [queue, resource, ...sqid] = url.pathname.slice(1).split("/").map(decodeURIComponent); + expect(resource).toBe("request"); + expect(decodePathSegment(queue!)).toBe(id); + expect(sqid.map(decodePathSegment).join("/")).toBe(`${id}/42`); + }); + + it("makes the history view shareable without consuming part of an opaque ID", () => { + expect(new WebPaths().request("demo-queue", "42", { view: "history" })).toBe("/demo-queue/request/42?view=history"); + }); +}); diff --git a/web/submitqueue/core/paths.ts b/web/submitqueue/core/paths.ts new file mode 100644 index 000000000..581be6807 --- /dev/null +++ b/web/submitqueue/core/paths.ts @@ -0,0 +1,54 @@ +import { encodePathSegment } from "./path-segment.js"; + +export { decodePathSegment } from "./path-segment.js"; + +export interface RequestsPathOptions { + pageToken?: string | null; +} + +export interface WebPathsOptions { + basePath?: string; +} + +export interface RequestPathOptions extends RequestsPathOptions { + view?: "summary" | "history"; +} + + +function encodePath(value: string): string { + return value.split("/").map(encodePathSegment).join("/"); +} + +export class WebPaths { + readonly basePath: string; + + constructor(options: WebPathsOptions = {}) { + const requestedPath = options.basePath ?? ""; + this.basePath = requestedPath.endsWith("/") + ? requestedPath.slice(0, -1) + : requestedPath; + } + + requests(queue: string, options: RequestsPathOptions = {}): string { + const parameters = new URLSearchParams(); + if (options.pageToken) { + parameters.set("page", options.pageToken); + } + const query = parameters.toString(); + const queuePath = `${this.basePath}/${encodePathSegment(queue)}`; + return query === "" ? queuePath : `${queuePath}?${query}`; + } + + directory(): string { + return this.basePath || "/"; + } + + request(queue: string, sqid: string, options: RequestPathOptions = {}): string { + const query = new URL(this.requests(queue, options), "http://paths.invalid").searchParams; + if (options.view === "history") { + query.set("view", "history"); + } + const suffix = query.toString(); + return `${this.requests(queue)}/request/${encodePath(sqid)}${suffix ? `?${suffix}` : ""}`; + } +} diff --git a/web/submitqueue/core/route.test.ts b/web/submitqueue/core/route.test.ts new file mode 100644 index 000000000..6de00150a --- /dev/null +++ b/web/submitqueue/core/route.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { WebPaths } from "./paths.js"; +import { parseRoute } from "./route.js"; + +function segments(href: string): string[] { + const { pathname } = new URL(href, "https://submitqueue.test"); + return pathname === "/" ? [] : pathname.slice(1).split("/"); +} + +describe("parseRoute", () => { + const paths = new WebPaths(); + + it.each([ + ["the directory", paths.directory(), { kind: "queues" }], + ["a queue", paths.requests("demo-queue"), { kind: "queue", queue: "demo-queue", search: {} }], + ["a dot-only queue", paths.requests(".."), { kind: "queue", queue: "..", search: {} }], + ["a slash-containing sqid", paths.request("demo-queue", "demo-queue/42"), + { kind: "request", queue: "demo-queue", sqid: "demo-queue/42", search: {} }], + ["an escaped sqid", paths.request("demo-queue", "opaque/id?query#~."), + { kind: "request", queue: "demo-queue", sqid: "opaque/id?query#~.", search: {} }], + ["a change", "/demo-queue/change/git/git.example.com/demo/refs%2Fheads%2Fmain", + { kind: "change", queue: "demo-queue", reference: ["git", "git.example.com", "demo", "refs/heads/main"], search: {} }], + ])("round-trips %s produced by WebPaths", (_name, href, route) => { + expect(parseRoute(segments(href))).toEqual(route); + }); + + it("carries search parameters to the page", () => { + expect(parseRoute(["demo-queue"], { page: "cursor" })).toEqual({ kind: "queue", queue: "demo-queue", search: { page: "cursor" } }); + }); + + it.each([ + ["an unknown resource", ["demo-queue", "unknown", "1"]], + ["a request without an ID", ["demo-queue", "request"]], + ["an empty segment", ["demo-queue", "", "1"]], + ["malformed percent-encoding", ["demo%E0%A4%A"]], + ])("does not route %s", (_name, value) => { + expect(parseRoute(value)).toBeNull(); + }); +}); diff --git a/web/submitqueue/core/route.ts b/web/submitqueue/core/route.ts new file mode 100644 index 000000000..6ed18c3b0 --- /dev/null +++ b/web/submitqueue/core/route.ts @@ -0,0 +1,58 @@ +import { decodePathSegment } from "./path-segment.js"; + +/** Raw query parameters of a page URL, as frameworks commonly expose them. */ +export type WebSearchParams = Readonly>; + +/** A decoded SubmitQueue page address below the host's mount point. */ +export type SubmitQueueWebRoute = + | { kind: "queues" } + | { kind: "queue"; queue: string; search?: WebSearchParams } + | { kind: "request"; queue: string; sqid: string; search?: WebSearchParams } + | { kind: "change"; queue: string; reference: readonly string[]; search?: WebSearchParams }; + +function decodeComponent(value: string): string | null { + try { + return decodeURIComponent(value); + } catch { + // Malformed percent-encoding is an unroutable address, not a failure. + return null; + } +} + +function decodeResourceSegment(value: string): string | null { + const decoded = decodeComponent(value); + return decoded === null ? null : decodePathSegment(decoded); +} + +/** + * Maps the still percent-encoded path segments below the mount point, as + * produced by {@link WebPaths}, to a route; `null` when no page matches. + */ +export function parseRoute(segments: readonly string[], search: WebSearchParams = {}): SubmitQueueWebRoute | null { + if (segments.length === 0) { + return { kind: "queues" }; + } + if (segments.some(segment => segment === "")) { + return null; + } + const [encodedQueue, resource, ...rest] = segments; + const queue = decodeResourceSegment(encodedQueue!); + if (queue === null || queue === "") { + return null; + } + if (resource === undefined) { + return { kind: "queue", queue, search }; + } + if (rest.length === 0) { + return null; + } + if (resource === "request") { + const parts = rest.map(decodeResourceSegment); + return parts.includes(null) ? null : { kind: "request", queue, sqid: parts.join("/"), search }; + } + if (resource === "change") { + const reference = rest.map(decodeComponent); + return reference.includes(null) ? null : { kind: "change", queue, reference: reference as string[], search }; + } + return null; +} diff --git a/web/submitqueue/core/status.test.ts b/web/submitqueue/core/status.test.ts new file mode 100644 index 000000000..8c75e2560 --- /dev/null +++ b/web/submitqueue/core/status.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { isTerminalStatus, knownRequestStatuses, statusDisplay } from "./status"; + +describe("request status display", () => { + it.each(["landed", "error", "cancelled"])("marks %s terminal", (status) => { + expect(isTerminalStatus(status)).toBe(true); + }); + + it.each(["accepted", "validating", "speculating", "landing", "cancelling"])( + "keeps %s active", + (status) => { + expect(isTerminalStatus(status)).toBe(false); + }, + ); + + it("renders newly-added statuses with a readable fallback", () => { + expect(statusDisplay("waiting_for_owner")).toEqual({ + label: "Waiting For Owner", + tone: "neutral", + terminal: false, + }); + expect(knownRequestStatuses).toContain("landed"); + }); +}); diff --git a/web/submitqueue/core/status.ts b/web/submitqueue/core/status.ts new file mode 100644 index 000000000..13cef1dec --- /dev/null +++ b/web/submitqueue/core/status.ts @@ -0,0 +1,49 @@ +import type { StatusDisplay } from "../entity/index.js"; + +const STATUS_DISPLAY: Readonly> = { + accepting: { label: "Accepting", tone: "progress", terminal: false }, + accepted: { label: "Accepted", tone: "progress", terminal: false }, + started: { label: "Started", tone: "progress", terminal: false }, + validating: { label: "Validating", tone: "progress", terminal: false }, + validated: { label: "Validated", tone: "progress", terminal: false }, + batching: { label: "Batching", tone: "progress", terminal: false }, + batched: { label: "Batched", tone: "progress", terminal: false }, + speculating: { label: "Speculating", tone: "progress", terminal: false }, + speculated: { label: "Speculated", tone: "progress", terminal: false }, + landing: { label: "Landing", tone: "progress", terminal: false }, + landed: { label: "Landed", tone: "success", terminal: true }, + cancelling: { label: "Cancelling", tone: "warning", terminal: false }, + cancelled: { label: "Cancelled", tone: "neutral", terminal: true }, + error: { label: "Error", tone: "danger", terminal: true }, +}; + +const UNKNOWN_STATUS: StatusDisplay = { + label: "Unknown", + tone: "neutral", + terminal: false, +}; + +export const knownRequestStatuses = Object.freeze(Object.keys(STATUS_DISPLAY)); + +export function statusDisplay(status: string): StatusDisplay { + const known = STATUS_DISPLAY[status]; + if (known !== undefined) { + return known; + } + const normalized = status.trim(); + if (normalized === "") { + return UNKNOWN_STATUS; + } + return { + ...UNKNOWN_STATUS, + label: normalized + .split(/[-_\s]+/u) + .filter(Boolean) + .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`) + .join(" "), + }; +} + +export function isTerminalStatus(status: string): boolean { + return statusDisplay(status).terminal; +} diff --git a/web/submitqueue/core/window.test.ts b/web/submitqueue/core/window.test.ts new file mode 100644 index 000000000..b4effcf4e --- /dev/null +++ b/web/submitqueue/core/window.test.ts @@ -0,0 +1,41 @@ +import { newHmacCursorCodec } from "../extension/cursor/hmac/index.js"; +import { describe, expect, it } from "vitest"; +import { decodeWindowCursor, defaultRequestWindow, encodeWindowCursor, REQUEST_WINDOW_MS } from "./window.js"; + +const codec = newHmacCursorCodec("test-pagination-secret"); +const queue = "demo-queue"; +const window = defaultRequestWindow(REQUEST_WINDOW_MS + 100); + +describe("live request window and snapshot pagination", () => { + it("advances the default window each time it is refreshed", () => { + expect(defaultRequestWindow(REQUEST_WINDOW_MS + 100)).toEqual({ fromMs: 100, toMs: REQUEST_WINDOW_MS + 100 }); + expect(defaultRequestWindow(REQUEST_WINDOW_MS + 200)).toEqual({ fromMs: 200, toMs: REQUEST_WINDOW_MS + 200 }); + }); + + it("round-trips stable gateway paging bounds inside one opaque cursor", async () => { + const cursor = await encodeWindowCursor(codec, queue, window, "opaque/token+=\nvalue"); + expect(await decodeWindowCursor(codec, queue, cursor)).toEqual({ ...window, pageToken: "opaque/token+=\nvalue" }); + }); + + it("rejects altered, cross-queue, or differently signed cursors", async () => { + const cursor = await encodeWindowCursor(codec, queue, window, "next"); + expect(await decodeWindowCursor(codec, "other-queue", cursor)).toBeUndefined(); + expect(await decodeWindowCursor(codec, queue, `${cursor}x`)).toBeUndefined(); + expect(await decodeWindowCursor(newHmacCursorCodec("other-secret"), queue, cursor)).toBeUndefined(); + }); + + it.each(["", "not-a-cursor", ".invalid", "a".repeat(16_385)])("rejects malformed cursor", async (cursor) => { + expect(await decodeWindowCursor(codec, queue, cursor)).toBeUndefined(); + }); + + it.each([ + ["a missing token", window, ""], + ["inverted bounds", { fromMs: 200, toMs: 100 }, "next"], + ])("rejects a signed cursor with %s", async (_name, bounds, token) => { + expect(await decodeWindowCursor(codec, queue, await encodeWindowCursor(codec, queue, bounds, token))).toBeUndefined(); + }); + + it("rejects a signed payload from another cursor format", async () => { + expect(await decodeWindowCursor(codec, queue, await codec.encode(queue, "v0\n1\n2\nnext"))).toBeUndefined(); + }); +}); diff --git a/web/submitqueue/core/window.ts b/web/submitqueue/core/window.ts new file mode 100644 index 000000000..7c42a0782 --- /dev/null +++ b/web/submitqueue/core/window.ts @@ -0,0 +1,46 @@ +import type { CursorCodec } from "../extension/cursor/index.js"; + +export const REQUEST_WINDOW_MS = 24 * 60 * 60 * 1_000; +export const REQUEST_PAGE_SIZE = 50; + +const CURSOR_VERSION = "v1"; + +export type RequestWindow = Readonly<{ + fromMs: number; + toMs: number; + pageToken?: string; +}>; + +export function defaultRequestWindow(nowMs = Date.now()): RequestWindow { + return { fromMs: nowMs - REQUEST_WINDOW_MS, toMs: nowMs }; +} + +function parseMillisecond(value: string | undefined): number | undefined { + if (!value || !/^\d+$/u.test(value)) { + return undefined; + } + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : undefined; +} + +/** Encodes a snapshot page — the original window bounds plus the gateway's token — as a scoped cursor. */ +export async function encodeWindowCursor(codec: CursorCodec, scope: string, window: RequestWindow, token: string): Promise { + return codec.encode(scope, [CURSOR_VERSION, window.fromMs, window.toMs, token].join("\n")); +} + +/** Decodes a cursor from {@link encodeWindowCursor}; `undefined` when it is foreign, tampered, or malformed. */ +export async function decodeWindowCursor(codec: CursorCodec, scope: string, cursor: string): Promise { + const payload = await codec.decode(scope, cursor); + if (payload === undefined) { + return undefined; + } + const [version, from, to, ...tokenParts] = payload.split("\n"); + const fromMs = parseMillisecond(from); + const toMs = parseMillisecond(to); + const pageToken = tokenParts.join("\n"); + if (version !== CURSOR_VERSION || fromMs === undefined || toMs === undefined || + toMs - fromMs !== REQUEST_WINDOW_MS || !pageToken) { + return undefined; + } + return { fromMs, toMs, pageToken }; +} diff --git a/web/submitqueue/entity/change.ts b/web/submitqueue/entity/change.ts new file mode 100644 index 000000000..25be8d80d --- /dev/null +++ b/web/submitqueue/entity/change.ts @@ -0,0 +1,35 @@ +import type { RequestSummaryModel } from "./request.js"; + +/** One request that submitted a version of a change. */ +export interface ChangeSubmissionModel { + /** The submitting request. */ + request: RequestSummaryModel; + /** Submitted version, such as a commit SHA or a revision number. */ + version: string; + /** In-app link to the submissions of exactly this version. */ + versionHref: string; +} + +/** Submissions of one change (a review or a Git ref) to one queue. */ +export interface ChangeDetailModel { + /** Queue whose requests were searched. */ + queue: string; + /** Code-review provider name, e.g. `GitHub`. */ + provider: string; + /** Provider host, lowercase, with an optional port. */ + host: string; + /** Review label, e.g. `PR #123`, `D123`, or a Git ref. */ + review: string; + /** Repository path; empty for providers without one. */ + repository: string; + /** The single version shown, or `null` when all versions are shown. */ + pinnedVersion: string | null; + /** In-app link to all versions of the change, when one exists. */ + logicalHref?: string; + /** Matching submissions, newest received first; repeated submissions stay separate. */ + submissions: ChangeSubmissionModel[]; + /** Receipt window that was scanned, or `null` when lookup was exact. */ + window: { fromMs: number; toMs: number } | null; + /** Links to continue or restart a bounded scan; absent when the scan was not paged. */ + pagination?: { nextHref: string | null; latestHref: string | null }; +} diff --git a/web/submitqueue/entity/index.ts b/web/submitqueue/entity/index.ts new file mode 100644 index 000000000..3ad00d404 --- /dev/null +++ b/web/submitqueue/entity/index.ts @@ -0,0 +1,11 @@ +export type { ChangeDetailModel, ChangeSubmissionModel } from "./change.js"; +export type { + ChangePageModel, QueueDirectoryPageModel, QueueRequestsPageModel, RequestPageModel, SubmitQueuePageModel, + WebPageModel, WebRefreshModel, +} from "./page.js"; +export type { QueueModel } from "./queue.js"; +export type { + HistoryEventModel, HistoryEventType, RequestDetailModel, RequestListModel, RequestSummaryModel, +} from "./request.js"; +export type { StatusDisplay, StatusTone } from "./status.js"; +export type { LoadResult, WebError, WebErrorKind } from "./result.js"; diff --git a/web/submitqueue/entity/page.ts b/web/submitqueue/entity/page.ts new file mode 100644 index 000000000..23ab52a02 --- /dev/null +++ b/web/submitqueue/entity/page.ts @@ -0,0 +1,93 @@ +import type { LoadResult } from "./result.js"; +import type { ChangeDetailModel } from "./change.js"; +import type { QueueModel } from "./queue.js"; +import type { RequestDetailModel, RequestListModel } from "./request.js"; + +/** How the client keeps a rendered page current. */ +export interface WebRefreshModel { + /** Whether polling has stopped because the page can no longer change, or is too costly to poll. */ + terminal: boolean; + /** Consecutive retryable failures behind this page; drives polling backoff. */ + transientFailureCount: number; + /** Page a refresh moves to instead of reloading this one, or `null` to reload in place. */ + refreshHref: string | null; +} + +/** Fields every serializable page model carries; `WebApp` relies on exactly these. */ +export interface WebPageModel { + /** Identity of the rendered resource; changes whenever client state must reset. */ + key: string; + /** Document title for the page, without any host-specific suffix. */ + title: string; + /** Refresh policy for the page. */ + refresh: WebRefreshModel; +} + +/** Fields shared by every SubmitQueue page model. */ +interface SubmitQueuePagePresentation extends WebPageModel { + /** Path prefix of every in-app link; empty when mounted at the root. */ + basePath: string; +} + +/** The queue directory. */ +export interface QueueDirectoryPageModel extends SubmitQueuePagePresentation { + /** Page discriminator. */ + kind: "queues"; + /** Configured queues, or why they could not be listed. */ + result: LoadResult; +} + +/** Requests one queue received in a 24-hour window. */ +export interface QueueRequestsPageModel extends SubmitQueuePagePresentation { + /** Page discriminator. */ + kind: "queue"; + /** Queue name. */ + queue: string; + /** Half-open receipt window shown, Unix epoch milliseconds. */ + window: { fromMs: number; toMs: number }; + /** Whether this is an older snapshot page rather than the live first page. */ + paged: boolean; + /** In-app link to the live first page. */ + latestHref: string; + /** The page of requests, or why it could not be loaded. */ + result: LoadResult; + /** In-app links for change URIs that have a change page, keyed by URI. */ + changeLinks: Record; + /** Display labels for change URIs, keyed by URI; unlabeled URIs show verbatim. */ + changeLabels: Record; +} + +/** One request's summary or history. */ +export interface RequestPageModel extends SubmitQueuePagePresentation { + /** Page discriminator. */ + kind: "request"; + /** Selected tab. */ + view: "summary" | "history"; + /** The request and its history, or why it could not be loaded. */ + result: LoadResult; + /** In-app link back to the request's queue. */ + backHref: string; + /** In-app link to the summary tab. */ + summaryHref: string; + /** In-app link to the history tab. */ + historyHref: string; + /** In-app links for change URIs that have a change page, keyed by URI. */ + changeLinks: Record; + /** Display labels for change URIs, keyed by URI. */ + changeLabels: Record; +} + +/** Submissions of one change. */ +export interface ChangePageModel extends SubmitQueuePagePresentation { + /** Page discriminator. */ + kind: "change"; + /** The change's submissions, or why they could not be loaded. */ + result: LoadResult; +} + +/** Any serializable SubmitQueue page. */ +export type SubmitQueuePageModel = + | QueueDirectoryPageModel + | QueueRequestsPageModel + | RequestPageModel + | ChangePageModel; diff --git a/web/submitqueue/entity/queue.ts b/web/submitqueue/entity/queue.ts new file mode 100644 index 000000000..31c678ef0 --- /dev/null +++ b/web/submitqueue/entity/queue.ts @@ -0,0 +1,7 @@ +/** One queue configured in the gateway. */ +export interface QueueModel { + /** Unique queue name. */ + name: string; + /** Optional description; empty when the gateway provides none. */ + description: string; +} diff --git a/web/submitqueue/entity/request.ts b/web/submitqueue/entity/request.ts new file mode 100644 index 000000000..cd91ef6f3 --- /dev/null +++ b/web/submitqueue/entity/request.ts @@ -0,0 +1,62 @@ +import type { WebError } from "./result.js"; + +/** One SubmitQueue request as shown in lists and detail pages. */ +export interface RequestSummaryModel { + /** Queue-scoped request ID; opaque, may contain `/`. */ + sqid: string; + /** Queue the request was submitted to. */ + queue: string; + /** Change URIs submitted together, in submission order. */ + changeUris: string[]; + /** Receipt time, Unix epoch milliseconds. */ + receivedAtMs: number; + /** Current lifecycle status; unknown values are kept verbatim. */ + status: string; + /** Most recent failure message, or `null` when none was recorded. */ + lastError: string | null; + /** Uninterpreted key/value metadata. */ + metadata: Record; +} + +/** Whether a history entry records a lifecycle status, an occurrence, or something unrecognized. */ +export type HistoryEventType = "status" | "event" | "unknown"; + +/** One entry of a request's append-only history. */ +export interface HistoryEventModel { + /** Time the entry was recorded, Unix epoch milliseconds. */ + timestampMs: number; + /** Entry kind. */ + type: HistoryEventType; + /** Lifecycle status for `status` entries, otherwise `null`. */ + status: string | null; + /** Occurrence name for `event` entries, otherwise `null`. */ + event: string | null; + /** Failure message recorded with the entry, or `null`. */ + lastError: string | null; + /** Uninterpreted key/value metadata. */ + metadata: Record; +} + +/** One page of requests received by a queue within a half-open window. */ +export interface RequestListModel { + /** Queue the requests belong to. */ + queue: string; + /** Inclusive window start, Unix epoch milliseconds. */ + receivedAtOrAfterMs: number; + /** Exclusive window end, Unix epoch milliseconds. */ + receivedBeforeMs: number; + /** Requests on this page, newest received first. */ + requests: RequestSummaryModel[]; + /** Opaque cursor for the next page within the same window, or `null` on the last page. */ + nextPageToken: string | null; +} + +/** A request with its retained history. */ +export interface RequestDetailModel { + /** The request's current summary. */ + request: RequestSummaryModel; + /** History in recording order; empty when `historyError` is set. */ + history: HistoryEventModel[]; + /** Why history could not be loaded, or `null` when it loaded. */ + historyError: WebError | null; +} diff --git a/web/submitqueue/entity/result.ts b/web/submitqueue/entity/result.ts new file mode 100644 index 000000000..6fdc1d2ae --- /dev/null +++ b/web/submitqueue/entity/result.ts @@ -0,0 +1,19 @@ +/** Origin of a failure shown to a viewer; unknown failures are `"internal"`. */ +export type WebErrorKind = "user" | "not-found" | "transient" | "internal"; + +/** A sanitized, viewer-safe description of a failed load. */ +export interface WebError { + /** Failure origin, used to select presentation and retry behavior. */ + kind: WebErrorKind; + /** Short heading for the failure. */ + title: string; + /** Viewer-facing explanation; never contains raw backend messages. */ + message: string; + /** Whether repeating the same load can succeed without viewer action. */ + retryable: boolean; +} + +/** Outcome of a load: either serializable data or a sanitized error. */ +export type LoadResult = + | { ok: true; data: T } + | { ok: false; error: WebError }; diff --git a/web/submitqueue/entity/status.ts b/web/submitqueue/entity/status.ts new file mode 100644 index 000000000..119707a00 --- /dev/null +++ b/web/submitqueue/entity/status.ts @@ -0,0 +1,12 @@ +/** Visual emphasis of a request status. */ +export type StatusTone = "neutral" | "progress" | "success" | "danger" | "warning"; + +/** How one request status string is presented. */ +export interface StatusDisplay { + /** Human-readable status name. */ + label: string; + /** Visual emphasis for the status. */ + tone: StatusTone; + /** Whether a request in this status can no longer change. */ + terminal: boolean; +} diff --git a/web/submitqueue/extension/cursor/cursor.ts b/web/submitqueue/extension/cursor/cursor.ts new file mode 100644 index 000000000..37ed20d29 --- /dev/null +++ b/web/submitqueue/extension/cursor/cursor.ts @@ -0,0 +1,10 @@ +/** + * Turns server-side continuation state into an opaque, tamper-evident URL + * value and back. A cursor is bound to `scope`: decoding it under any other + * scope, or decoding a modified cursor, yields `undefined`. Either method may + * be asynchronous so a codec can use remote key management or WebCrypto. + */ +export interface CursorCodec { + encode(scope: string, payload: string): string | Promise; + decode(scope: string, cursor: string): string | undefined | Promise; +} diff --git a/web/submitqueue/extension/cursor/hmac/hmac.test.ts b/web/submitqueue/extension/cursor/hmac/hmac.test.ts new file mode 100644 index 000000000..9db35d132 --- /dev/null +++ b/web/submitqueue/extension/cursor/hmac/hmac.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; + +import { newHmacCursorCodec } from "./index.js"; + +describe("newHmacCursorCodec", () => { + const codec = newHmacCursorCodec("test-secret"); + + it("round-trips a payload within its scope", () => { + const cursor = codec.encode("demo-queue", "v1\n100\n200\ntoken/with\nnewline") as string; + expect(cursor).toMatch(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u); + expect(codec.decode("demo-queue", cursor)).toBe("v1\n100\n200\ntoken/with\nnewline"); + }); + + it.each([ + ["another scope", codec, "other-queue", (cursor: string) => cursor], + ["a modified payload", codec, "demo-queue", (cursor: string) => `A${cursor}`], + ["another secret", newHmacCursorCodec("other-secret"), "demo-queue", (cursor: string) => cursor], + ["malformed input", codec, "demo-queue", () => "not a cursor"], + ])("rejects %s", (_name, decoder, scope, alter) => { + expect(decoder.decode(scope, alter(codec.encode("demo-queue", "payload") as string))).toBeUndefined(); + }); + + it("refuses an empty secret", () => { + expect(() => newHmacCursorCodec("")).toThrow(); + }); +}); diff --git a/web/submitqueue/extension/cursor/hmac/hmac.ts b/web/submitqueue/extension/cursor/hmac/hmac.ts new file mode 100644 index 000000000..68173dc80 --- /dev/null +++ b/web/submitqueue/extension/cursor/hmac/hmac.ts @@ -0,0 +1,36 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import type { CursorCodec } from "../cursor.js"; + +const MAX_CURSOR_LENGTH = 16_384; +const CURSOR_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u; + +/** + * An HMAC-SHA256 {@link CursorCodec} for Node. Cursors are readable base64url + * payloads with a signature over scope and payload, so they are tamper-evident + * but not confidential; never put secrets in a payload. + */ +export function newHmacCursorCodec(secret: string): CursorCodec { + if (!secret) { + throw new Error("The cursor signing secret must be nonempty"); + } + const sign = (scope: string, body: string) => + createHmac("sha256", secret).update(`${encodeURIComponent(scope)}\n${body}`).digest(); + return { + encode(scope, payload) { + const body = Buffer.from(payload, "utf8").toString("base64url"); + return `${body}.${sign(scope, body).toString("base64url")}`; + }, + decode(scope, cursor) { + if (cursor.length > MAX_CURSOR_LENGTH || !CURSOR_PATTERN.test(cursor)) { + return undefined; + } + const [body, signature] = cursor.split(".") as [string, string]; + const actual = Buffer.from(signature, "base64url"); + const expected = sign(scope, body); + if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) { + return undefined; + } + return Buffer.from(body, "base64url").toString("utf8"); + }, + }; +} diff --git a/web/submitqueue/extension/cursor/hmac/index.ts b/web/submitqueue/extension/cursor/hmac/index.ts new file mode 100644 index 000000000..f1119ce31 --- /dev/null +++ b/web/submitqueue/extension/cursor/hmac/index.ts @@ -0,0 +1 @@ +export { newHmacCursorCodec } from "./hmac.js"; diff --git a/web/submitqueue/extension/cursor/index.ts b/web/submitqueue/extension/cursor/index.ts new file mode 100644 index 000000000..ae4a3220e --- /dev/null +++ b/web/submitqueue/extension/cursor/index.ts @@ -0,0 +1 @@ +export type { CursorCodec } from "./cursor.js"; diff --git a/web/submitqueue/extension/gateway/gateway.ts b/web/submitqueue/extension/gateway/gateway.ts new file mode 100644 index 000000000..eb86d464c --- /dev/null +++ b/web/submitqueue/extension/gateway/gateway.ts @@ -0,0 +1,84 @@ +import type { Int64Value } from "../../core/int64.js"; + +/** + * Structural contract for the SubmitQueue gateway reads the web UI needs. A + * Protobuf-ES / Connect client satisfies it directly; any other transport + * (protobufjs, another RPC stack, a test fake) satisfies it through a thin adapter. + * Requests use Protobuf-ES types (`bigint` for int64), so adapters convert + * them; responses accept any common int64 representation. + */ + +/** Per-call options; deadlines normally belong to the host's transport. */ +export interface GatewayCallOptions { + timeoutMs?: number; +} + +export interface GatewayRequestSummary { + sqid: string; + queue: string; + changeUris: readonly string[]; + receivedAtMs: Int64Value; + status: string; + lastError: string; + metadata: Readonly>; +} + +export interface GatewayHistoryEvent { + timestampMs: Int64Value; + status: string; + lastError: string; + metadata: Readonly>; + type: string; + event: string; +} + +export interface GatewayReader { + list( + input: { + queue: string; + receivedAtOrAfterMs: bigint; + receivedBeforeMs: bigint; + pageSize: number; + pageToken: string; + }, + options?: GatewayCallOptions, + ): Promise<{ + requests: readonly GatewayRequestSummary[]; + nextPageToken: string; + }>; + getRequestSummaryByID( + input: { sqid: string; queue: string }, + options?: GatewayCallOptions, + ): Promise<{ + request?: GatewayRequestSummary | undefined; + }>; + getRequestHistoryByID( + input: { sqid: string; queue: string }, + options?: GatewayCallOptions, + ): Promise<{ + events: readonly GatewayHistoryEvent[]; + }>; +} + +export interface GatewayQueueReader { + listQueues( + input: Record, + options?: GatewayCallOptions, + ): Promise<{ queues: readonly { name: string }[] }>; +} + +export interface GatewayChangeReader { + getRequestSummaryByChangeURI( + input: { queue: string; changeUri: string }, + options?: GatewayCallOptions, + ): Promise<{ requests: readonly GatewayRequestSummary[] }>; +} + +/** Every gateway read the SubmitQueue UI performs. */ +export type SubmitQueueGateway = GatewayReader & GatewayQueueReader & GatewayChangeReader; + +/** Transport-neutral outcome of a failed gateway call. */ +export type GatewayErrorCode = "invalid-argument" | "not-found" | "resource-exhausted" | "unavailable" | "internal"; + +/** Maps a rejected gateway call to a {@link GatewayErrorCode}; must not throw. */ +export type GatewayErrorClassifier = (error: unknown) => GatewayErrorCode; diff --git a/web/submitqueue/extension/gateway/grpcstatus/grpcstatus.test.ts b/web/submitqueue/extension/gateway/grpcstatus/grpcstatus.test.ts new file mode 100644 index 000000000..80a713062 --- /dev/null +++ b/web/submitqueue/extension/gateway/grpcstatus/grpcstatus.test.ts @@ -0,0 +1,27 @@ +import { Code, ConnectError } from "@connectrpc/connect"; +import { describe, expect, it } from "vitest"; + +import { grpcStatusErrorCode } from "./index.js"; + +describe("grpcStatusErrorCode", () => { + it.each([ + [Code.InvalidArgument, "invalid-argument"], + [Code.NotFound, "not-found"], + [Code.ResourceExhausted, "resource-exhausted"], + [Code.Unavailable, "unavailable"], + [Code.DeadlineExceeded, "unavailable"], + [Code.PermissionDenied, "internal"], + ] as const)("classifies Connect code %s", (code, expected) => { + expect(grpcStatusErrorCode(new ConnectError("detail", code))).toBe(expected); + }); + + it.each([ + ["a grpc-js style status object", Object.assign(new Error("unavailable"), { code: 14 }), "unavailable"], + ["a Node system error", Object.assign(new Error("refused"), { code: "ECONNREFUSED" }), "internal"], + ["an HTTP-like status", { code: 404 }, "internal"], + ["a plain error", new Error("boom"), "internal"], + ["a non-object", "boom", "internal"], + ])("classifies %s", (_name, error, expected) => { + expect(grpcStatusErrorCode(error)).toBe(expected); + }); +}); diff --git a/web/submitqueue/extension/gateway/grpcstatus/grpcstatus.ts b/web/submitqueue/extension/gateway/grpcstatus/grpcstatus.ts new file mode 100644 index 000000000..49dab0da4 --- /dev/null +++ b/web/submitqueue/extension/gateway/grpcstatus/grpcstatus.ts @@ -0,0 +1,34 @@ +import type { GatewayErrorCode } from "../gateway.js"; + +// gRPC status codes, shared by Connect's `Code` enum and @grpc/grpc-js. +const GRPC_INVALID_ARGUMENT = 3; +const GRPC_DEADLINE_EXCEEDED = 4; +const GRPC_NOT_FOUND = 5; +const GRPC_RESOURCE_EXHAUSTED = 8; +const GRPC_UNAVAILABLE = 14; +const GRPC_MAX_CODE = 16; + +/** + * Classifies any error that carries a numeric gRPC status `code`, which + * covers Connect and @grpc/grpc-js without importing either. Anything else + * is `internal`. + */ +export function grpcStatusErrorCode(error: unknown): GatewayErrorCode { + const code = typeof error === "object" && error !== null ? (error as { code?: unknown }).code : undefined; + if (typeof code !== "number" || !Number.isInteger(code) || code < 0 || code > GRPC_MAX_CODE) { + return "internal"; + } + switch (code) { + case GRPC_INVALID_ARGUMENT: + return "invalid-argument"; + case GRPC_NOT_FOUND: + return "not-found"; + case GRPC_RESOURCE_EXHAUSTED: + return "resource-exhausted"; + case GRPC_UNAVAILABLE: + case GRPC_DEADLINE_EXCEEDED: + return "unavailable"; + default: + return "internal"; + } +} diff --git a/web/submitqueue/extension/gateway/grpcstatus/index.ts b/web/submitqueue/extension/gateway/grpcstatus/index.ts new file mode 100644 index 000000000..ec100cce3 --- /dev/null +++ b/web/submitqueue/extension/gateway/grpcstatus/index.ts @@ -0,0 +1 @@ +export { grpcStatusErrorCode } from "./grpcstatus.js"; diff --git a/web/submitqueue/extension/gateway/index.ts b/web/submitqueue/extension/gateway/index.ts new file mode 100644 index 000000000..7b2e3a3b7 --- /dev/null +++ b/web/submitqueue/extension/gateway/index.ts @@ -0,0 +1,4 @@ +export type { + GatewayCallOptions, GatewayChangeReader, GatewayErrorClassifier, GatewayErrorCode, GatewayHistoryEvent, + GatewayQueueReader, GatewayReader, GatewayRequestSummary, SubmitQueueGateway, +} from "./gateway.js"; diff --git a/web/submitqueue/extension/gateway/mock/fake.ts b/web/submitqueue/extension/gateway/mock/fake.ts new file mode 100644 index 000000000..83da90a66 --- /dev/null +++ b/web/submitqueue/extension/gateway/mock/fake.ts @@ -0,0 +1,85 @@ +import type { GatewayHistoryEvent, GatewayRequestSummary, SubmitQueueGateway } from "../gateway.js"; + +const DEFAULT_TIMESTAMP = 1_700_000_000_000n; + +export function gatewayRequestFixture( + overrides: Partial = {}, +): GatewayRequestSummary { + return { + sqid: "demo-queue/1", + queue: "demo-queue", + changeUris: ["github://github.com/uber/submitqueue/pull/1/0123456789012345678901234567890123456789"], + receivedAtMs: DEFAULT_TIMESTAMP, + status: "speculating", + lastError: "", + metadata: {}, + ...overrides, + }; +} + +export function gatewayHistoryFixture( + overrides: Partial = {}, +): GatewayHistoryEvent { + return { + timestampMs: DEFAULT_TIMESTAMP, + status: "started", + lastError: "", + metadata: {}, + type: "status", + event: "", + ...overrides, + }; +} + +export interface FakeGatewayReaderOptions { + queues?: readonly { name: string }[]; + queuesError?: unknown; + requests?: readonly GatewayRequestSummary[]; + nextPageToken?: string; + summary?: GatewayRequestSummary; + history?: readonly GatewayHistoryEvent[]; + listError?: unknown; + summaryError?: unknown; + historyError?: unknown; + changeRequests?: readonly GatewayRequestSummary[]; + changeError?: unknown; +} + +export function createFakeGatewayReader(options: FakeGatewayReaderOptions = {}): SubmitQueueGateway { + const summary = options.summary ?? options.requests?.[0] ?? gatewayRequestFixture(); + return { + async listQueues() { + if (options.queuesError !== undefined) { + throw options.queuesError; + } + return { queues: options.queues ?? [{ name: "demo-queue" }] }; + }, + async list() { + if (options.listError !== undefined) { + throw options.listError; + } + return { + requests: options.requests ?? [summary], + nextPageToken: options.nextPageToken ?? "", + }; + }, + async getRequestSummaryByID() { + if (options.summaryError !== undefined) { + throw options.summaryError; + } + return { request: summary }; + }, + async getRequestHistoryByID() { + if (options.historyError !== undefined) { + throw options.historyError; + } + return { events: options.history ?? [gatewayHistoryFixture()] }; + }, + async getRequestSummaryByChangeURI() { + if (options.changeError !== undefined) { + throw options.changeError; + } + return { requests: options.changeRequests ?? options.requests ?? [summary] }; + }, + }; +} diff --git a/web/submitqueue/extension/gateway/mock/index.ts b/web/submitqueue/extension/gateway/mock/index.ts new file mode 100644 index 000000000..dfc34df5a --- /dev/null +++ b/web/submitqueue/extension/gateway/mock/index.ts @@ -0,0 +1,3 @@ +export { + createFakeGatewayReader, gatewayHistoryFixture, gatewayRequestFixture, type FakeGatewayReaderOptions, +} from "./fake.js"; diff --git a/web/submitqueue/index.ts b/web/submitqueue/index.ts new file mode 100644 index 000000000..e26da0af8 --- /dev/null +++ b/web/submitqueue/index.ts @@ -0,0 +1,14 @@ +"use client"; + +// Client-safe entry: the app, page frames, navigation, and page-model types. Loading lives in "./server". +export { + SubmitQueueApp, SubmitQueuePage, SubmitQueueShell, SubmitQueueStatePage, + type SubmitQueuePageProps, type SubmitQueueShellProps, +} from "./view/index.js"; +export { WebNavigationProvider, type WebLinkProps, type WebNavigation } from "./view/navigation.js"; +export type { + ChangeDetailModel, ChangePageModel, ChangeSubmissionModel, HistoryEventModel, HistoryEventType, LoadResult, + QueueDirectoryPageModel, QueueModel, QueueRequestsPageModel, RequestDetailModel, RequestListModel, + RequestPageModel, RequestSummaryModel, StatusDisplay, StatusTone, SubmitQueuePageModel, WebError, WebErrorKind, + WebPageModel, WebRefreshModel, +} from "./entity/index.js"; diff --git a/web/submitqueue/module.test.ts b/web/submitqueue/module.test.ts new file mode 100644 index 000000000..3bc506c8e --- /dev/null +++ b/web/submitqueue/module.test.ts @@ -0,0 +1,268 @@ +import { Code, ConnectError } from "@connectrpc/connect"; +import type { Meter } from "@opentelemetry/api"; +import type { Logger } from "pino"; +import { describe, expect, it, vi } from "vitest"; +import { createSubmitQueueWeb, type SubmitQueueWebOptions } from "./module.js"; +import { createFakeGatewayReader, gatewayHistoryFixture, gatewayRequestFixture } from "./extension/gateway/mock/index.js"; +import type { SubmitQueueGateway } from "./extension/gateway/index.js"; +import { newHmacCursorCodec } from "./extension/cursor/hmac/index.js"; +import { WebPaths } from "./core/paths.js"; +import { REQUEST_WINDOW_MS } from "./core/window.js"; + +const now = 1_700_000_000_000; +const cursors = newHmacCursorCodec("host-owned-test-secret"); +const paths = new WebPaths({ basePath: "/sq" }); +const sha = "0123456789abcdef0123456789abcdef01234567"; +const gitUri = `git://git.example.com/demo/refs%2Fheads%2Fmain/${sha}`; +const changePath = "/sq/demo-queue/change/git/git.example.com/demo/refs%2Fheads%2Fmain"; +const githubPath = "/sq/demo-queue/change/github/github.com/uber/submitqueue/pull/123"; + +function newWeb(options: Partial = {}) { + const web = createSubmitQueueWeb({ gateway: createFakeGatewayReader(), cursors, paths, ...options }); + return (path: string, search: Record = {}, extra: { principal?: unknown; requestId?: string } = {}) => + web.handle({ path, search, ...extra }); +} + +function recordingMeter() { + const record = vi.fn(); + return { meter: { createHistogram: () => ({ record }) } as unknown as Meter, record }; +} + +function recordingLogger() { + const warn = vi.fn(); + const error = vi.fn(); + const child = vi.fn((): Logger => logger); + const logger = { child, debug: vi.fn(), info: vi.fn(), warn, error } as unknown as Logger; + return { logger, child, warn, error }; +} + +function nextPageToken(result: Awaited>>): string { + if (result.kind !== "render" || result.model.kind !== "queue" || !result.model.result.ok) { + throw new Error("Expected a queue page"); + } + return result.model.result.data.nextPageToken!; +} + +describe("createSubmitQueueWeb", () => { + it("loads configured queues and emits a serializable page without transport/framework setup", async () => { + const get = newWeb({ gateway: createFakeGatewayReader({ queues: [{ name: "empty-queue" }, { name: "demo-queue" }] }) }); + const result = await get("/sq"); + expect(result).toMatchObject({ + kind: "render", + model: { kind: "queues", title: "Queues", basePath: "/sq", result: { ok: true, data: [{ name: "empty-queue" }, { name: "demo-queue" }] } }, + }); + expect(() => JSON.stringify(result)).not.toThrow(); + }); + + it.each([ + ["outside the mount", "/elsewhere"], + ["an unknown page", "/sq/demo-queue/unknown/x"], + ["malformed percent-encoding", "/sq/%E0%A4%A"], + ])("reports not-found for %s", async (_name, path) => { + expect(await newWeb()(path)).toEqual({ kind: "not-found" }); + }); + + it("loads live pages and resumes signed snapshot cursors without moving their bounds", async () => { + let clock = now; + const gateway = createFakeGatewayReader({ nextPageToken: "opaque/gateway-token" }); + const list = vi.spyOn(gateway, "list"); + const get = newWeb({ gateway, now: () => clock }); + const first = await get("/sq/demo-queue"); + expect(first).toMatchObject({ + kind: "render", model: { kind: "queue", paged: false, latestHref: "/sq/demo-queue", refresh: { terminal: false } }, + }); + const page = nextPageToken(first); + clock += 10_000; + expect(await get("/sq/demo-queue", { page })).toMatchObject({ + kind: "render", model: { + kind: "queue", paged: true, window: { fromMs: now - REQUEST_WINDOW_MS, toMs: now }, + refresh: { terminal: true, refreshHref: "/sq/demo-queue" }, + }, + }); + expect(list).toHaveBeenLastCalledWith(expect.objectContaining({ + pageToken: "opaque/gateway-token", receivedAtOrAfterMs: BigInt(now - REQUEST_WINDOW_MS), receivedBeforeMs: BigInt(now), + })); + expect(await get("/sq/demo-queue")).toMatchObject({ kind: "render", model: { window: { toMs: clock } } }); + }); + + it.each([ + { from: "1", to: "2" }, + { page: ["multiple", "cursors"] }, + { page: "tampered" }, + ])("normalizes stale or invalid queue inputs to a live URL: %j", async search => { + expect(await newWeb()("/sq/demo-queue", search)).toEqual({ kind: "redirect", href: "/sq/demo-queue" }); + }); + + it("keeps request IDs opaque and provides complete history navigation and refresh policy", async () => { + const sqid = "legacy/id%2Fvalue"; + const gateway = createFakeGatewayReader({ + summary: gatewayRequestFixture({ sqid, status: "landed" }), + history: [gatewayHistoryFixture({ status: "landed" })], + }); + const summary = vi.spyOn(gateway, "getRequestSummaryByID"); + const result = await newWeb({ gateway })("/sq/demo-queue/request/legacy/id%252Fvalue", { view: "history" }); + expect(summary).toHaveBeenCalledWith({ sqid, queue: "demo-queue" }); + expect(result).toMatchObject({ + kind: "render", model: { + kind: "request", view: "history", title: `${sqid} · History`, + historyHref: "/sq/demo-queue/request/legacy/id%252Fvalue?view=history", + refresh: { terminal: true }, result: { ok: true, data: { request: { sqid } } }, + }, + }); + }); + + it("accepts asynchronous cursor infrastructure without putting promises in page URLs", async () => { + const get = newWeb({ + gateway: createFakeGatewayReader({ nextPageToken: "gateway-continuation" }), + cursors: { + encode: async (...args) => cursors.encode(...args), + decode: async (...args) => cursors.decode(...args), + }, + now: () => now, + }); + const page = nextPageToken(await get("/sq/demo-queue")); + expect(typeof page).toBe("string"); + expect(await get("/sq/demo-queue", { page })).toMatchObject({ kind: "render", model: { paged: true, window: { toMs: now } } }); + }); + + it("returns explicit not-found decisions without exceptions for absent resources", async () => { + const get = newWeb(); + expect(await get("/sq/unknown")).toEqual({ kind: "not-found" }); + expect(await get("/sq/demo-queue/change/unknown")).toEqual({ kind: "not-found" }); + }); + + it("keeps catalog failures visible rather than declaring a configured queue missing", async () => { + const gateway = createFakeGatewayReader({ queuesError: new ConnectError("upstream failure", Code.Unavailable) }); + const list = vi.spyOn(gateway, "list"); + expect(await newWeb({ gateway })("/sq/demo-queue")).toMatchObject({ + kind: "render", model: { result: { ok: false, error: { kind: "transient" } }, refresh: { terminal: false } }, + }); + expect(list).not.toHaveBeenCalled(); + }); + + it.each([ + ["a logical GitHub change, which scans the receipt window", githubPath, true], + ["a Git ref, which scans the receipt window", changePath, true], + ["a pinned GitHub version, which is one exact lookup", `${githubPath}/${sha}`, false], + ])("refresh policy for %s (terminal: %s)", async (_name, path, terminal) => { + expect(await newWeb()(path)).toMatchObject({ kind: "render", model: { refresh: { terminal } } }); + }); + + it("loads pinned reviews through exact-URI lookup and preserves base-path change links", async () => { + const uri = `github://github.com/uber/submitqueue/pull/123/${sha}`; + const gateway = createFakeGatewayReader({ changeRequests: [gatewayRequestFixture({ changeUris: [uri] })] }); + const read = vi.spyOn(gateway, "getRequestSummaryByChangeURI"); + const list = vi.spyOn(gateway, "list"); + const result = await newWeb({ gateway })(`${githubPath}/${sha}`); + expect(read).toHaveBeenCalledWith({ queue: "demo-queue", changeUri: uri }); + expect(list).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + kind: "render", model: { result: { ok: true, data: { window: null, submissions: [{ versionHref: `${githubPath}/${sha}` }] } } }, + }); + }); + + it("scopes change continuation to queue, reference, and version while preserving the snapshot", async () => { + const gateway = createFakeGatewayReader({ requests: [gatewayRequestFixture({ changeUris: [gitUri] })] }); + const list = vi.spyOn(gateway, "list").mockImplementation(async input => { + const page = input.pageToken ? Number(input.pageToken.slice(5)) : 0; + return { requests: [gatewayRequestFixture({ sqid: String(page + 100), changeUris: [gitUri] })], nextPageToken: page < 10 ? `page-${page + 1}` : "" }; + }); + const get = newWeb({ gateway, now: () => now }); + const first = await get(changePath); + if (first.kind !== "render" || first.model.kind !== "change" || !first.model.result.ok) { + throw new Error("Expected a change page"); + } + const page = new URL(first.model.result.data.pagination!.nextHref!, "http://host.invalid").searchParams.get("page")!; + expect(first.model.result.data.submissions[0]?.versionHref).toBe(`${changePath}/${sha}`); + expect(await get(`${changePath}/${sha}`, { page })).toEqual({ kind: "redirect", href: `${changePath}/${sha}` }); + expect(list).toHaveBeenCalledTimes(10); + expect(await get(changePath, { page })).toMatchObject({ + kind: "render", + model: { refresh: { terminal: true, refreshHref: changePath }, result: { ok: true, data: { pagination: { nextHref: null } } } }, + }); + expect(list).toHaveBeenLastCalledWith(expect.objectContaining({ + pageToken: "page-10", receivedAtOrAfterMs: BigInt(now - REQUEST_WINDOW_MS), receivedBeforeMs: BigInt(now), + })); + }); + + it("refuses a queue-page cursor on a change page", async () => { + const get = newWeb({ + gateway: createFakeGatewayReader({ nextPageToken: "gateway-continuation", requests: [gatewayRequestFixture({ changeUris: [gitUri] })] }), + now: () => now, + }); + const queuePageHref = paths.requests("demo-queue", { pageToken: nextPageToken(await get("/sq/demo-queue")) }); + const page = new URL(queuePageHref, "https://host.invalid").searchParams.get("page")!; + expect(await get(changePath, { page })).toEqual({ kind: "redirect", href: changePath }); + }); + + it("resolves the gateway per request, so authorization-dependent visibility is never cached", async () => { + let gateway: SubmitQueueGateway = createFakeGatewayReader({ queues: [{ name: "first" }], requests: [] }); + const resolve = vi.fn(() => gateway); + const get = newWeb({ gateway: resolve }); + expect(await get("/sq/first", {}, { principal: "viewer" })).toMatchObject({ kind: "render", model: { result: { ok: true } } }); + expect(resolve).toHaveBeenCalledWith(expect.objectContaining({ principal: "viewer" })); + gateway = createFakeGatewayReader({ queues: [{ name: "second" }], requests: [] }); + expect(await get("/sq/first")).toEqual({ kind: "not-found" }); + expect(await get("/sq/second")).toMatchObject({ kind: "render", model: { result: { ok: true } } }); + }); + + it("reports forbidden without reading the gateway when authorization fails", async () => { + const gateway = createFakeGatewayReader(); + const listQueues = vi.spyOn(gateway, "listQueues"); + const authorize = vi.fn(() => false); + expect(await newWeb({ gateway, authorize })("/sq/demo-queue", {}, { principal: "viewer" })).toEqual({ kind: "forbidden" }); + expect(authorize).toHaveBeenCalledWith("viewer", { kind: "queue", queue: "demo-queue", search: {} }); + expect(listQueues).not.toHaveBeenCalled(); + }); + + it("logs upstream failures with the request ID while showing a sanitized error", async () => { + const cause = new ConnectError("upstream failure", Code.Unavailable); + const { logger, child, warn } = recordingLogger(); + const get = newWeb({ gateway: createFakeGatewayReader({ queuesError: cause }), logger }); + expect(await get("/sq", {}, { requestId: "request-7" })).toMatchObject({ + kind: "render", model: { result: { ok: false, error: { kind: "transient" } } }, + }); + expect(child).toHaveBeenCalledWith({ requestId: "request-7" }); + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ operation: "queues", err: cause, code: "unavailable" }), expect.any(String)); + }); + + it("records each page load's outcome", async () => { + const { meter, record } = recordingMeter(); + await newWeb({ meter })("/sq/demo-queue", { page: "tampered" }); + expect(record).toHaveBeenCalledWith(expect.any(Number), { page: "queue", outcome: "redirect" }); + }); + + it("logs, records, and rethrows an unexpected failure", async () => { + const failure = new Error("bug"); + const { logger, error } = recordingLogger(); + const { meter, record } = recordingMeter(); + const get = newWeb({ gateway: () => { throw failure; }, logger, meter }); + await expect(get("/sq")).rejects.toBe(failure); + expect(error).toHaveBeenCalledWith(expect.objectContaining({ err: failure }), "Web page load failed"); + expect(record).toHaveBeenCalledWith(expect.any(Number), { page: "queues", outcome: "exception" }); + }); + + it("caches successful catalogs per key until they expire", async () => { + let clock = now; + const gateway = createFakeGatewayReader(); + const listQueues = vi.spyOn(gateway, "listQueues"); + const get = newWeb({ gateway, now: () => clock, queuesCache: { ttlMs: 1_000, key: context => String(context.principal) } }); + await get("/sq", {}, { principal: "a" }); + await get("/sq", {}, { principal: "a" }); + expect(listQueues).toHaveBeenCalledTimes(1); + await get("/sq", {}, { principal: "b" }); + expect(listQueues).toHaveBeenCalledTimes(2); + clock += 1_001; + await get("/sq", {}, { principal: "a" }); + expect(listQueues).toHaveBeenCalledTimes(3); + }); + + it("does not cache a failed catalog", async () => { + const gateway = createFakeGatewayReader({ queuesError: new ConnectError("down", Code.Unavailable) }); + const listQueues = vi.spyOn(gateway, "listQueues"); + const get = newWeb({ gateway, queuesCache: { ttlMs: 60_000, key: () => "all" } }); + await get("/sq"); + await get("/sq"); + expect(listQueues).toHaveBeenCalledTimes(2); + }); +}); diff --git a/web/submitqueue/module.ts b/web/submitqueue/module.ts new file mode 100644 index 000000000..6e5e794e3 --- /dev/null +++ b/web/submitqueue/module.ts @@ -0,0 +1,184 @@ +import { SpanStatusCode, type Meter, type Tracer } from "@opentelemetry/api"; +import type { Logger } from "pino"; +import { beginOperation, defaultMeter, defaultTracer, silentLogger } from "./core/observability.js"; +import { WebPaths } from "./core/paths.js"; +import { parseRoute, type SubmitQueueWebRoute, type WebSearchParams } from "./core/route.js"; +import { loadChangePage } from "./controller/change.js"; +import { loadDirectoryPage, loadQueueDirectory } from "./controller/directory.js"; +import type { GatewayLoadOptions } from "./controller/gateway-call.js"; +import { notFound, type PageDependencies, type SubmitQueueWebResult } from "./controller/page.js"; +import { loadQueuePage } from "./controller/queue.js"; +import { loadRequestPage } from "./controller/request.js"; +import type { LoadResult, QueueModel } from "./entity/index.js"; +import type { CursorCodec } from "./extension/cursor/index.js"; +import type { GatewayErrorClassifier, SubmitQueueGateway } from "./extension/gateway/index.js"; + +export type { SubmitQueueWebResult } from "./controller/page.js"; + +/** Per-request values the host passes to every load. */ +export interface SubmitQueueRequestContext { + /** Identity the host authenticated; opaque to the module. */ + principal?: unknown; + /** Correlation ID for logs and traces. */ + requestId?: string; + /** Aborted when the viewer's request is abandoned. */ + signal?: AbortSignal; +} + +/** One page request, in framework-neutral terms. */ +export interface SubmitQueueWebRequest { + /** URL path from the host root, still percent-encoded, starting with `/`. */ + path: string; + search: WebSearchParams; + principal?: unknown; + /** Correlation ID; generated when absent. */ + requestId?: string; + signal?: AbortSignal; +} + +/** Caches successful catalogs per key; the key must separate viewers whose gateway visibility differs. */ +export interface QueueCatalogCache { + ttlMs: number; + key(context: SubmitQueueRequestContext): string; +} + +export interface SubmitQueueWebOptions { + /** The gateway, or a resolver called once per load; resolve per request when visibility depends on the viewer. */ + gateway: SubmitQueueGateway | ((context: SubmitQueueRequestContext) => SubmitQueueGateway); + /** Signs snapshot-page cursors. */ + cursors: CursorCodec; + /** Builds links; its base path is where the host mounts the UI. */ + paths?: WebPaths; + /** Decides whether the principal may see a route; `false` is reported as `forbidden`. */ + authorize?(principal: unknown, route: SubmitQueueWebRoute): boolean | Promise; + /** Maps transport errors to codes; defaults to gRPC status codes (Connect, grpc-js). */ + classifyError?: GatewayErrorClassifier; + /** Caches the queue catalog (`ListQueues`) across loads; absent means every load reads it. */ + queuesCache?: QueueCatalogCache; + logger?: Logger; + meter?: Meter; + tracer?: Tracer; + now?: () => number; + newRequestId?: () => string; +} + +export interface SubmitQueueWeb { + /** Serves one page request; never throws for expected outcomes such as missing resources. */ + handle(request: SubmitQueueWebRequest): Promise; +} + +type QueueCatalogLoader = (gateway: SubmitQueueGateway, context: SubmitQueueRequestContext) => Promise>; + +function cacheQueueCatalog(load: QueueCatalogLoader, cache: QueueCatalogCache, now: () => number): QueueCatalogLoader { + const entries = new Map }>(); + return async (gateway, context) => { + const key = cache.key(context); + const cached = entries.get(key); + if (cached && cached.expiresAtMs > now()) { + return cached.catalog; + } + const catalog = await load(gateway, context); + const loadedAtMs = now(); + for (const [cachedKey, entry] of entries) { + if (entry.expiresAtMs <= loadedAtMs) { + entries.delete(cachedKey); + } + } + if (catalog.ok) { + entries.set(key, { expiresAtMs: loadedAtMs + cache.ttlMs, catalog }); + } else { + entries.delete(key); + } + return catalog; + }; +} + +function pathSegments(path: string): string[] { + return path.split("/").filter((segment, index, all) => index > 0 && !(segment === "" && index === all.length - 1)); +} + +/** + * Builds the SubmitQueue web UI. The host routes every page path to + * `handle` and renders a `render` result with `SubmitQueueApp`. Nothing is + * cached unless `queuesCache` is set. + */ +export function createSubmitQueueWeb(options: SubmitQueueWebOptions): SubmitQueueWeb { + const paths = options.paths ?? new WebPaths(); + const mount = paths.basePath.split("/").filter(Boolean); + const now = () => options.now?.() ?? Date.now(); + const logger = options.logger ?? silentLogger; + const meter = options.meter ?? defaultMeter(); + const tracer = options.tracer ?? defaultTracer(); + const newRequestId = options.newRequestId ?? (() => globalThis.crypto.randomUUID()); + const baseGatewayOptions: GatewayLoadOptions = { + ...(options.classifyError ? { classifyError: options.classifyError } : {}), + meter, + tracer, + }; + const readQueues: QueueCatalogLoader = (gateway, context) => + loadQueueDirectory(gateway, { ...baseGatewayOptions, logger: logger.child({ requestId: context.requestId }) }); + const loadQueues = options.queuesCache ? cacheQueueCatalog(readQueues, options.queuesCache, now) : readQueues; + + async function load(route: SubmitQueueWebRoute, context: SubmitQueueRequestContext): Promise { + const gateway = typeof options.gateway === "function" ? options.gateway(context) : options.gateway; + const gatewayOptions = { ...baseGatewayOptions, logger: logger.child({ requestId: context.requestId }) }; + const catalog = await loadQueues(gateway, context); + const deps: PageDependencies = { gateway, cursors: options.cursors, paths, now, gatewayOptions, catalog }; + if (route.kind === "queues") { + return loadDirectoryPage(deps); + } + if (!route.queue || (catalog.ok && !catalog.data.some(queue => queue.name === route.queue))) { + return notFound; + } + const search = route.search ?? {}; + switch (route.kind) { + case "queue": + return loadQueuePage(deps, route.queue, search); + case "request": + return route.sqid ? loadRequestPage(deps, route.queue, route.sqid, search) : notFound; + case "change": + return loadChangePage(deps, route.queue, route.reference, search); + } + } + + return { + async handle(request) { + const segments = pathSegments(request.path); + if (!mount.every((segment, index) => segments[index] === segment)) { + return notFound; + } + const route = parseRoute(segments.slice(mount.length), request.search); + if (route === null) { + return notFound; + } + const requestId = request.requestId ?? newRequestId(); + const requestLogger = logger.child({ requestId }); + if (options.authorize && !(await options.authorize(request.principal, route))) { + requestLogger.info({ path: request.path }, "Web page request was not authorized"); + return { kind: "forbidden" }; + } + const context: SubmitQueueRequestContext = { + requestId, + ...(request.principal === undefined ? {} : { principal: request.principal }), + ...(request.signal ? { signal: request.signal } : {}), + }; + const operation = beginOperation(meter, "web.page_load", { page: route.kind }); + return tracer.startActiveSpan("web.page_load", { attributes: { "web.page": route.kind } }, async (span): Promise => { + try { + const result = await load(route, context); + operation.end(result.kind); + span.setAttribute("web.outcome", result.kind); + return result; + } catch (cause) { + operation.end("exception"); + span.recordException(cause instanceof Error ? cause : String(cause)); + span.setStatus({ code: SpanStatusCode.ERROR }); + requestLogger.error({ path: request.path, err: cause }, "Web page load failed"); + throw cause; + } finally { + span.end(); + } + }); + }, + }; +} diff --git a/web/submitqueue/package.json b/web/submitqueue/package.json new file mode 100644 index 000000000..fd86246a1 --- /dev/null +++ b/web/submitqueue/package.json @@ -0,0 +1,58 @@ +{ + "name": "@submitqueue/web-submitqueue", + "version": "0.0.0", + "private": true, + "type": "module", + "sideEffects": [ + "./view/styles.css" + ], + "files": [ + "dist", + "view/styles.css" + ], + "exports": { + "./styles.css": "./view/styles.css", + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./server": { + "types": "./dist/server.d.ts", + "import": "./dist/server.js" + }, + "./extension/gateway/mock": { + "types": "./dist/extension/gateway/mock/index.d.ts", + "import": "./dist/extension/gateway/mock/index.js" + }, + "./extension/cursor/hmac": { + "types": "./dist/extension/cursor/hmac/index.d.ts", + "import": "./dist/extension/cursor/hmac/index.js" + } + }, + "scripts": { + "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json", + "lint": "tsc --noEmit", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9 <2", + "pino": ">=10 <11", + "react": ">=18 <20" + }, + "devDependencies": { + "@connectrpc/connect": "^2.1.1", + "@opentelemetry/api": "^1.9.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.3.0", + "@types/node": "22.18.6", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "jsdom": "^26.1.0", + "pino": "^10.4.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "typescript": "^5.8.3", + "vitest": "4.0.7" + } +} diff --git a/web/submitqueue/server.ts b/web/submitqueue/server.ts new file mode 100644 index 000000000..7fef45406 --- /dev/null +++ b/web/submitqueue/server.ts @@ -0,0 +1,12 @@ +// Server entry: the page handler and the contracts a host implements for it. +export { + createSubmitQueueWeb, + type QueueCatalogCache, type SubmitQueueRequestContext, type SubmitQueueWeb, type SubmitQueueWebOptions, + type SubmitQueueWebRequest, type SubmitQueueWebResult, +} from "./module.js"; +export * from "./extension/gateway/index.js"; +export { grpcStatusErrorCode } from "./extension/gateway/grpcstatus/index.js"; +export * from "./extension/cursor/index.js"; +export type * from "./entity/index.js"; +export type { SubmitQueueWebRoute, WebSearchParams } from "./core/route.js"; +export { WebPaths, type RequestPathOptions, type RequestsPathOptions, type WebPathsOptions } from "./core/paths.js"; diff --git a/web/submitqueue/test-setup.ts b/web/submitqueue/test-setup.ts new file mode 100644 index 000000000..f149f27ae --- /dev/null +++ b/web/submitqueue/test-setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/web/submitqueue/tsconfig.build.json b/web/submitqueue/tsconfig.build.json new file mode 100644 index 000000000..0667c1a1c --- /dev/null +++ b/web/submitqueue/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "declarationMap": true, + "noEmit": false, + "outDir": "dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["dist", "node_modules", "**/*.test.ts", "**/*.test.tsx", "test-setup.ts", "vitest.config.ts"] +} diff --git a/web/submitqueue/tsconfig.json b/web/submitqueue/tsconfig.json new file mode 100644 index 000000000..b5c8499cb --- /dev/null +++ b/web/submitqueue/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "declaration": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node", "vitest/globals", "@testing-library/jest-dom"] + }, + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["dist", "node_modules"] +} diff --git a/web/submitqueue/view/app.tsx b/web/submitqueue/view/app.tsx new file mode 100644 index 000000000..5070c4c7f --- /dev/null +++ b/web/submitqueue/view/app.tsx @@ -0,0 +1,10 @@ +"use client"; + +import { WebApp } from "./web-app.js"; +import type { SubmitQueuePageModel } from "../entity/index.js"; +import { SubmitQueuePage } from "./pages.js"; + +/** The complete client experience for a loaded page: navigation, refresh, and polling through the host's `WebNavigationProvider`. */ +export function SubmitQueueApp({ model }: { model: SubmitQueuePageModel }) { + return ; +} diff --git a/web/submitqueue/view/auto-refresh.test.tsx b/web/submitqueue/view/auto-refresh.test.tsx new file mode 100644 index 000000000..39dc44d58 --- /dev/null +++ b/web/submitqueue/view/auto-refresh.test.tsx @@ -0,0 +1,152 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { AutoRefresh } from "./auto-refresh.js"; + +describe("AutoRefresh", () => { + beforeEach(() => { + vi.useFakeTimers(); + Object.defineProperty(navigator, "onLine", { configurable: true, value: true }); + Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); + }); + + afterEach(() => vi.useRealTimers()); + + it("uses the interval plus jitter and continues polling", async () => { + const refresh = vi.fn(async () => undefined); + render( 0.5} refresh={refresh} />); + + await act(async () => vi.advanceTimersByTime(2_249)); + expect(refresh).not.toHaveBeenCalled(); + await act(async () => vi.advanceTimersByTime(1)); + expect(refresh).toHaveBeenCalledTimes(1); + await act(async () => vi.advanceTimersByTime(2_250)); + expect(refresh).toHaveBeenCalledTimes(2); + }); + + it("keeps manual and timer refreshes single-flight beyond multiple intervals", async () => { + let finish: (() => void) | undefined; + const refresh = vi.fn(() => new Promise((resolve) => (finish = resolve))); + render(); + + await act(async () => vi.advanceTimersByTime(100)); + fireEvent.click(screen.getByRole("button", { name: "Refreshing…" })); + await act(async () => vi.advanceTimersByTime(500)); + expect(refresh).toHaveBeenCalledTimes(1); + await act(async () => finish?.()); + }); + + it("backs off transient failures up to the configured cap", async () => { + const refresh = vi.fn(async () => undefined); + render( + 0} + refresh={refresh} + transientFailureCount={4} + />, + ); + + await act(async () => vi.advanceTimersByTime(29_999)); + expect(refresh).not.toHaveBeenCalled(); + await act(async () => vi.advanceTimersByTime(1)); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("progressively backs off consecutive server failures", async () => { + const refresh = vi.fn(async () => undefined); + render( + 0} + refresh={refresh} + transientFailureCount={1} + />, + ); + + await act(async () => vi.advanceTimersByTime(200)); + expect(refresh).toHaveBeenCalledTimes(1); + await act(async () => vi.advanceTimersByTime(399)); + expect(refresh).toHaveBeenCalledTimes(1); + await act(async () => vi.advanceTimersByTime(1)); + expect(refresh).toHaveBeenCalledTimes(2); + await act(async () => vi.advanceTimersByTime(799)); + expect(refresh).toHaveBeenCalledTimes(2); + await act(async () => vi.advanceTimersByTime(1)); + expect(refresh).toHaveBeenCalledTimes(3); + }); + + it("backs off after a rejected refresh and recovers once a refresh succeeds", async () => { + const refresh = vi.fn<() => Promise>().mockRejectedValueOnce(new Error("network")).mockResolvedValue(undefined); + render( 0} refresh={refresh} />); + + await act(async () => vi.advanceTimersByTime(100)); + expect(refresh).toHaveBeenCalledTimes(1); + expect(screen.getByText("Updates unavailable · retrying")).toBeTruthy(); + await act(async () => vi.advanceTimersByTime(199)); + expect(refresh).toHaveBeenCalledTimes(1); + await act(async () => vi.advanceTimersByTime(1)); + expect(refresh).toHaveBeenCalledTimes(2); + expect(screen.getByText("Live")).toBeTruthy(); + await act(async () => vi.advanceTimersByTime(100)); + expect(refresh).toHaveBeenCalledTimes(3); + }); + + it("pauses while offline and resumes when online", async () => { + Object.defineProperty(navigator, "onLine", { configurable: true, value: false }); + const refresh = vi.fn(async () => undefined); + render(); + + expect(screen.getByText("Automatic refresh paused")).toBeTruthy(); + await act(async () => vi.advanceTimersByTime(1_000)); + expect(refresh).not.toHaveBeenCalled(); + Object.defineProperty(navigator, "onLine", { configurable: true, value: true }); + fireEvent(window, new Event("online")); + await act(async () => vi.advanceTimersByTime(100)); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("pauses while hidden and resumes when visible", async () => { + Object.defineProperty(document, "visibilityState", { configurable: true, value: "hidden" }); + const refresh = vi.fn(async () => undefined); + render(); + + await act(async () => vi.advanceTimersByTime(1_000)); + expect(refresh).not.toHaveBeenCalled(); + Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible" }); + fireEvent(document, new Event("visibilitychange")); + await act(async () => vi.advanceTimersByTime(100)); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("stops automatic polling for terminal detail views", async () => { + const refresh = vi.fn(async () => undefined); + render(); + + await act(async () => vi.advanceTimersByTime(1_000)); + expect(refresh).not.toHaveBeenCalled(); + expect(screen.getByText("Automatic refresh stopped")).toBeTruthy(); + await act(async () => fireEvent.click(screen.getByRole("button", { name: "Refresh" }))); + expect(refresh).toHaveBeenCalledTimes(1); + }); + + it("does not poll a terminal list failure", async () => { + const refresh = vi.fn(async () => undefined); + render( + , + ); + + await act(async () => vi.advanceTimersByTime(1_000)); + expect(refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/web/submitqueue/view/auto-refresh.tsx b/web/submitqueue/view/auto-refresh.tsx new file mode 100644 index 000000000..5af366b3d --- /dev/null +++ b/web/submitqueue/view/auto-refresh.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface AutoRefreshProps { + terminal?: boolean; + /** Whether to poll; when false only the manual button refreshes. */ + automatic?: boolean; + intervalMs?: number; + jitterMs?: number; + maxBackoffMs?: number; + transientFailureCount?: number; + refresh: () => void | Promise; + random?: () => number; +} + +function pageCanRefresh(): boolean { + return document.visibilityState !== "hidden" && navigator.onLine; +} + +export function AutoRefresh({ + terminal = false, + automatic = true, + intervalMs = 2_000, + jitterMs = 500, + maxBackoffMs = 30_000, + transientFailureCount = 0, + refresh, + random = Math.random, +}: AutoRefreshProps) { + const [refreshing, setRefreshing] = useState(false); + const [paused, setPaused] = useState(false); + const [cycle, setCycle] = useState(0); + const inFlight = useRef(false); + const localFailures = useRef(transientFailureCount); + const latestTransientFailureCount = useRef(transientFailureCount); + const timer = useRef | null>(null); + latestTransientFailureCount.current = transientFailureCount; + + const runRefresh = useCallback(async () => { + if (inFlight.current || !pageCanRefresh()) { + setPaused(!pageCanRefresh()); + return; + } + inFlight.current = true; + setRefreshing(true); + try { + await refresh(); + const serverFailures = latestTransientFailureCount.current; + localFailures.current = + serverFailures === 0 + ? 0 + : Math.max(localFailures.current + 1, serverFailures); + } catch { + localFailures.current = Math.max( + localFailures.current + 1, + latestTransientFailureCount.current, + ); + } finally { + inFlight.current = false; + setRefreshing(false); + setCycle((value) => value + 1); + } + }, [refresh]); + + useEffect(() => { + const updateAvailability = () => setPaused(!pageCanRefresh()); + document.addEventListener("visibilitychange", updateAvailability); + window.addEventListener("online", updateAvailability); + window.addEventListener("offline", updateAvailability); + updateAvailability(); + return () => { + document.removeEventListener("visibilitychange", updateAvailability); + window.removeEventListener("online", updateAvailability); + window.removeEventListener("offline", updateAvailability); + }; + }, []); + + useEffect(() => { + if (terminal || paused || !automatic) { + return; + } + const failures = Math.max(transientFailureCount, localFailures.current); + const multiplier = 2 ** Math.min(failures, 20); + const backoff = Math.min(intervalMs * multiplier, maxBackoffMs); + const delay = backoff + Math.floor(random() * (jitterMs + 1)); + timer.current = setTimeout(() => { + void runRefresh(); + }, delay); + return () => { + if (timer.current !== null) { + clearTimeout(timer.current); + timer.current = null; + } + }; + }, [automatic, cycle, intervalMs, jitterMs, maxBackoffMs, paused, random, runRefresh, terminal, transientFailureCount]); + + return ( +
+ + + {terminal ? "Automatic refresh stopped" : !automatic ? "Manual refresh" : paused ? "Automatic refresh paused" : + transientFailureCount > 0 || localFailures.current > 0 ? "Updates unavailable · retrying" : "Live"} + +
+ ); +} diff --git a/web/submitqueue/view/components.test.tsx b/web/submitqueue/view/components.test.tsx new file mode 100644 index 000000000..6a4de86a4 --- /dev/null +++ b/web/submitqueue/view/components.test.tsx @@ -0,0 +1,204 @@ +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WebNavigationProvider } from "./navigation.js"; +import { WebApp } from "./web-app.js"; +import { ChangeSubmissions, QueueDirectory, RequestDetail, RequestList, RequestListView, RequestStatus } from "./components.js"; +import type { ChangeDetailModel, RequestDetailModel, RequestListModel } from "../entity/index.js"; + +const request = { + sqid: "demo-queue/1", + queue: "demo-queue", + changeUris: ["github://github.com/uber/submitqueue/pull/1/abc"], + receivedAtMs: 1_700_000_000_000, + status: "speculating", + lastError: null, + metadata: { batch: "batch-1" }, +}; + +describe("request components", () => { + it("renders a linked queue request with its complete sqid", () => { + const model: RequestListModel = { + queue: "demo-queue", + receivedAtOrAfterMs: 1, + receivedBeforeMs: 2, + requests: [request], + nextPageToken: "opaque-token", + }; + render(); + + const link = screen.getByRole("link", { name: "demo-queue/1" }); + expect(link.getAttribute("href")).toBe( + "/demo-queue/request/demo-queue/1", + ); + expect(screen.getByText("Speculating").getAttribute("data-tone")).toBe("progress"); + expect(screen.getByText("Nov 14, 2023, 10:13:20 PM UTC")).toBeTruthy(); + expect(screen.getByRole("link", { name: "Next page" }).getAttribute("href")).toBe( + "/demo-queue?page=opaque-token", + ); + }); + + it("renders an accessible empty state", () => { + render( + , + ); + expect(screen.getByText("No requests were received in this window.")).toBeTruthy(); + }); + + it("renders ordered status/build history with expandable metadata", () => { + const model: RequestDetailModel = { + request: { ...request, status: "landed" }, + history: [ + { + timestampMs: 1_700_000_000_000, + type: "status", + status: "started", + event: null, + lastError: null, + metadata: {}, + }, + { + timestampMs: 1_700_000_001_000, + type: "event", + status: null, + event: "building", + lastError: null, + metadata: { build_url: "https://build.example/1" }, + }, + ], + historyError: null, + }; + render(); + + expect(screen.getByRole("heading", { level: 1 }).textContent).toContain("demo-queue/1"); + const history = screen.getByRole("list", { name: "Request history" }); + expect(within(history).getAllByRole("listitem")).toHaveLength(2); + expect(within(history).getByText("Building")).toBeTruthy(); + fireEvent.click(within(history).getByText("Building")); + expect(within(history).getByText("https://build.example/1")).toBeTruthy(); + fireEvent.change(screen.getByRole("combobox"), { target: { value: "event" } }); + expect(within(history).getAllByRole("listitem")).toHaveLength(1); + }); + + it("lists configured queues even when there is only one", () => { + render(); + expect(screen.getByRole("link", { name: "demo-queue" }).getAttribute("href")).toBe("/demo-queue"); + }); + + it("filters only the displayed page without discarding its pagination link", () => { + render(); + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "landed" } }); + expect(screen.queryByRole("link", { name: request.sqid })).toBeNull(); + expect(screen.getByRole("link", { name: "42" })).toBeTruthy(); + expect(screen.getByRole("link", { name: "Next page" })).toBeTruthy(); + }); + + it("uses host-supplied change labels and links without exposing fake file hints", () => { + const raw = "git://git.example.com/demo/refs%2Fheads%2Fmain/sha?sq-files=demo%2Ffile.txt"; + const clean = raw.split("?")[0]!; + render(); + expect(screen.getByRole("link", { name: clean }).getAttribute("href")).toBe("/change"); + expect(screen.queryByText(/sq-files=/)).toBeNull(); + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "file.txt" } }); + expect(screen.getByText("No displayed requests match.")).toBeTruthy(); + }); + + it("retains the last successful list on a transient failure, but not a permanent failure", () => { + const view = render(); + const error = { kind: "transient" as const, title: "Unavailable", message: "Retry", retryable: true }; + view.rerender(); + expect(screen.getByRole("link", { name: request.sqid })).toBeTruthy(); + expect(screen.getByText(/last successful snapshot/)).toBeTruthy(); + view.rerender(); + expect(screen.queryByRole("link", { name: request.sqid })).toBeNull(); + }); + + it("does not describe unavailable history as empty and copies the opaque ID", async () => { + const writeText = vi.fn(async () => undefined); + Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } }); + render(); + expect(screen.getByText("History unavailable")).toBeTruthy(); + expect(screen.queryByText("No history has been retained for this request.")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Copy ID" })); + await vi.waitFor(() => expect(writeText).toHaveBeenCalledWith(request.sqid)); + }); + + it("keeps repeated submissions for the same version separate", () => { + render( ({ + request: { ...request, sqid }, version: "same-sha", versionHref: "/version", + })), + }} />); + const table = screen.getByRole("table", { name: "Change submissions" }); + expect(within(table).getAllByText("same-sha")).toHaveLength(2); + expect(within(table).getByRole("link", { name: "38" }).getAttribute("href")).toBe("/demo-queue/request/38"); + }); + + it("navigates between versions through the enclosing app and keeps full versions in links", () => { + const push = vi.fn(); + const version = "0123456789abcdef0123456789abcdef01234567"; + const change: ChangeDetailModel = { + queue: "demo-queue", provider: "GitHub", host: "github.com", + repository: "uber/submitqueue", review: "PR #123", pinnedVersion: null, + logicalHref: "/logical", window: { fromMs: 1, toMs: 2 }, + submissions: [{ request, version, versionHref: `/logical/${version}` }], + }; + const page = { key: "change", title: "PR #123", refresh: { terminal: true, transientFailureCount: 0, refreshHref: null } }; + render( + } /> + ); + fireEvent.change(screen.getByRole("combobox"), { target: { value: `/logical/${version}` } }); + expect(push).toHaveBeenCalledWith(`/logical/${version}`); + expect(screen.getByRole("link", { name: version }).getAttribute("href")).toBe(`/logical/${version}`); + }); + + it.each([ + { nextHref: "/change?page=next", latestHref: null }, + { nextHref: null, latestHref: "/change" }, + ])("renders page-scoped history and host-provided navigation: %j", pagination => { + render(); + expect(screen.getByText("No submissions were found on this page.")).toBeTruthy(); + expect(screen.queryByText("No submissions were found in this scope.")).toBeNull(); + expect(screen.getByRole("status").textContent).toContain("Showing matches from this page"); + if (pagination.nextHref) { + expect(screen.getByRole("link", { name: "Continue history scan" }).getAttribute("href")).toBe(pagination.nextHref); + expect(screen.getByRole("status").textContent).toContain("More queue requests remain"); + } else { + expect(screen.queryByRole("link", { name: "Continue history scan" })).toBeNull(); + expect(screen.getByRole("link", { name: "Latest history" }).getAttribute("href")).toBe(pagination.latestHref); + } + }); + + it("shows unknown statuses safely", () => { + render(); + expect(screen.getByText("New Pipeline Step").getAttribute("data-tone")).toBe("neutral"); + }); +}); diff --git a/web/submitqueue/view/components.tsx b/web/submitqueue/view/components.tsx new file mode 100644 index 000000000..588dbd832 --- /dev/null +++ b/web/submitqueue/view/components.tsx @@ -0,0 +1,236 @@ +"use client"; + +import { ErrorState } from "./error-state.js"; +import { useLastSuccessfulData } from "./last-successful-data.js"; +import { Timestamp } from "./timestamp.js"; +import { useWebApp, WebLink } from "./web-app.js"; +import { useState, type ReactNode } from "react"; +import type { + ChangeDetailModel, HistoryEventModel, LoadResult, QueueModel, RequestDetailModel, RequestListModel, RequestSummaryModel, +} from "../entity/index.js"; +import { WebPaths } from "../core/paths.js"; +import { statusDisplay } from "../core/status.js"; + +function Metadata({ values }: { values: Record }) { + return
{Object.entries(values).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => +
{key}
{value}
+ )}
; +} + +export function RequestStatus({ status }: { status: string }) { + const display = statusDisplay(status); + return {display.label}; +} + +function RequestChanges({ request, links = {}, labels = {} }: { + request: RequestSummaryModel; links?: Record; labels?: Record; +}) { + return
    {request.changeUris.map(uri => +
  • {links[uri] ? {labels[uri] ?? uri} : {labels[uri] ?? uri}}
  • + )}
; +} + +export function QueueDirectory({ queues, basePath }: { queues: readonly QueueModel[]; basePath?: string }) { + const paths = new WebPaths(basePath === undefined ? {} : { basePath }); + return
+

Queues

Available submit queues

+ {queues.length === 0 ?

No queues are configured.

: +
    {queues.map(queue =>
  • +

    {queue.name}

    {queue.description ?

    {queue.description}

    : null} +
  • )}
} +
; +} + +export interface RequestListProps { + model: RequestListModel; + basePath?: string; + empty?: ReactNode; + changeLinks?: Record; + changeLabels?: Record; +} + +export function RequestList({ model, basePath, empty, changeLinks, changeLabels }: RequestListProps) { + const [search, setSearch] = useState(""); + const paths = new WebPaths(basePath === undefined ? {} : { basePath }); + const requests = model.requests.filter(request => + [request.sqid, request.status, ...request.changeUris.map(uri => changeLabels?.[uri] ?? uri)] + .some(value => value.toLowerCase().includes(search.toLowerCase())) + ); + return
+
+ + Displayed page · UTC +
+ {model.requests.length === 0 ?
{empty ?? "No requests were received in this window."}
: + requests.length === 0 ?

No displayed requests match.

: +
+ + {requests.map(request => + + + + + )} +
Request IDStatusChangesReceived · UTC
{request.sqid}{request.lastError ?

{request.lastError}

: null}
} +
{requests.length} displayed requests · newest received first + {model.nextPageToken ? : null} +
+
; +} + +function HistoryValue({ item }: { item: HistoryEventModel }) { + if (item.type === "status" && item.status !== null) { + return ; + } + return {item.type === "event" && item.event !== null ? statusDisplay(item.event).label : "Unknown event"}; +} + +export function RequestHistory({ events }: { events: HistoryEventModel[] }) { + const [filter, setFilter] = useState("all"); + const shown = events.filter(event => filter === "all" || event.type === filter); + return
+

Request history

+ {events.length === 0 ?

No history has been retained for this request.

: +
    {shown.map((item, index) => +
  1. + +
    + {Object.keys(item.metadata).length ? :

    No additional metadata.

    } +
    + {item.type === "status" ? "Lifecycle" : item.type === "event" ? "Occurrence" : "Unknown"} + {item.lastError ?

    {item.lastError}

    : null} +
  2. + )}
} +

{shown.length} displayed events · oldest first · UTC

+
; +} + +/** Shows the result's data, keeping the last successful data visible below the error while a failure is retryable. */ +export function LastSuccessfulData({ result, fallback = null, children }: { + result: LoadResult; + /** Rendered when there is no data to show. */ + fallback?: ReactNode; + children: (data: T) => ReactNode; +}) { + const data = useLastSuccessfulData(result); + return <> + {!result.ok ? <>{data !== undefined ?

Showing the last successful snapshot; updates are unavailable.

: null} : null} + {data !== undefined ? children(data) : fallback} + ; +} + +export function RequestListView({ result, ...props }: Omit & { + result: LoadResult; +}) { + return {data => }; +} + +function CopyControls({ id }: { id: string }) { + const [message, setMessage] = useState(""); + const copyValue = async (value: string, label: string) => { + try { + await navigator.clipboard.writeText(value); + setMessage(`${label} copied`); + } catch { + setMessage("Copy unavailable. Select the ID or copy the browser address."); + } + }; + return
+ + + {message} +
; +} + +export interface RequestDetailProps { + model: RequestDetailModel; + view?: "summary" | "history"; + basePath?: string; + backHref?: string; + summaryHref?: string; + historyHref?: string; + changeLinks?: Record; + changeLabels?: Record; + controls?: ReactNode; +} + +export function RequestDetail({ model, view = "summary", basePath, backHref, summaryHref, historyHref, changeLinks, changeLabels, controls }: RequestDetailProps) { + const { request } = model; + const paths = new WebPaths(basePath === undefined ? {} : { basePath }); + const latest = model.history.at(-1); + return
+ +

{request.sqid}

{controls}
+ {request.lastError ?

{request.lastError}

: null} + + {view === "history" ? model.historyError ? : : +
+
Queue
{request.queue}
Request ID
{request.sqid}
+
Received · UTC
+
Last recorded event
{model.historyError ? "History unavailable" : latest ? <> : "No retained events"}
+
Last error
{request.lastError ?? "None reported"}
+

Changes

+
Request metadata{Object.keys(request.metadata).length ? :

No additional metadata.

}
+ {model.historyError ? : null} +
} + {view === "summary" && model.historyError === null ? +
    + {model.history.filter(event => event.type === "status" && event.status !== null).map((event, index) => +
  1. {statusDisplay(event.status!).label}
  2. + )} +
: null} +
; +} + +export function RequestDetailView({ result, ...props }: Omit & { + result: LoadResult; +}) { + return {data => }; +} + +export interface ChangeSubmissionsProps { + model: ChangeDetailModel; + basePath?: string; +} + +export function ChangeSubmissions({ model, basePath }: ChangeSubmissionsProps) { + const app = useWebApp(); + const paths = new WebPaths(basePath === undefined ? {} : { basePath }); + const versions = [...new Map(model.submissions.map(item => [item.versionHref, item.version])).entries()]; + const selectedVersion = model.pinnedVersion === null ? model.logicalHref : + model.submissions.find(item => item.version === model.pinnedVersion)?.versionHref; + return
+ +

{model.review}

{model.pinnedVersion ? `Submitted version: ${model.pinnedVersion}` : "Submissions across versions"}

+ {model.pagination ?

+ Showing matches from this page of queue requests only. + {model.pagination.nextHref ? " More queue requests remain to be searched." : " The history scan is complete for this receipt window."} +

: null} +
Provider
{model.provider}
Host
{model.host}
Repository
{model.repository || "—"}
Queue
{model.queue}
+

Submission history

+ {model.logicalHref && app && versions.length > 0 ? + : + model.pinnedVersion !== null && model.logicalHref ? All submitted versions : null} +
+ {model.window ?

Receipt window: — . This is not unlimited retained history.

:

Retained submissions for this exact version

} + {model.submissions.length === 0 ?

{model.pagination ? "No submissions were found on this page." : "No submissions were found in this scope."}

: +
+ {model.submissions.map(item => + + )}
RequestSubmitted versionReceived · UTCStatus
{item.request.sqid}{item.version.length > 20 ? `${item.version.slice(0, 8)}…${item.version.slice(-4)}` : item.version}{item.request.lastError ?

{item.request.lastError}

: null}
} +

{model.submissions.length} requests{model.pagination ? " on this page" : ""} · newest received first · repeated submissions remain separate

+ {model.pagination ? : null} +
; +} diff --git a/web/submitqueue/view/error-state.tsx b/web/submitqueue/view/error-state.tsx new file mode 100644 index 000000000..2791a3698 --- /dev/null +++ b/web/submitqueue/view/error-state.tsx @@ -0,0 +1,9 @@ +"use client"; + +import type { WebError } from "../entity/index.js"; + +export function ErrorState({ error, compact = false }: { error: WebError; compact?: boolean }) { + return
+

{error.title}

{error.message}

+
; +} diff --git a/web/submitqueue/view/frame.test.tsx b/web/submitqueue/view/frame.test.tsx new file mode 100644 index 000000000..66ebf0c16 --- /dev/null +++ b/web/submitqueue/view/frame.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { ErrorState } from "./error-state.js"; +import { WebShell } from "./shell.js"; + +const internalError = { + kind: "internal", + title: "Something went wrong", + message: "The page could not load this information.", + retryable: false, +} as const; + +describe("ErrorState", () => { + it.each([ + ["full-page", false, "alert"], + ["compact", true, "status"], + ] as const)("announces a %s error with the %s role", (_name, compact, role) => { + render(); + expect(screen.getByRole(role).textContent).toContain("Something went wrong"); + }); +}); + +describe("WebShell", () => { + it("omits the environment label and footer when the host clears them", () => { + render(
content
); + expect(screen.getByRole("link").textContent).toBe("Example"); + expect(screen.queryByRole("contentinfo")).toBeNull(); + }); +}); diff --git a/web/submitqueue/view/index.ts b/web/submitqueue/view/index.ts new file mode 100644 index 000000000..84a9a72a9 --- /dev/null +++ b/web/submitqueue/view/index.ts @@ -0,0 +1,6 @@ +"use client"; + +export { SubmitQueueApp } from "./app.js"; +export { + SubmitQueuePage, SubmitQueueShell, SubmitQueueStatePage, type SubmitQueuePageProps, type SubmitQueueShellProps, +} from "./pages.js"; diff --git a/web/submitqueue/view/last-successful-data.test.tsx b/web/submitqueue/view/last-successful-data.test.tsx new file mode 100644 index 000000000..56c1f52eb --- /dev/null +++ b/web/submitqueue/view/last-successful-data.test.tsx @@ -0,0 +1,30 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { LoadResult } from "../entity/index.js"; +import { useLastSuccessfulData } from "./last-successful-data.js"; + +const loaded: LoadResult = { ok: true, data: "snapshot" }; +const transient: LoadResult = { ok: false, error: { kind: "transient", title: "Unavailable", message: "Try again", retryable: true } }; +const permanent: LoadResult = { ok: false, error: { kind: "internal", title: "Failed", message: "Contact support", retryable: false } }; + +describe("useLastSuccessfulData", () => { + it("keeps the last data through retryable failures and clears it on a permanent one", () => { + const { result, rerender } = renderHook(({ value }: { value: LoadResult }) => useLastSuccessfulData(value), { + initialProps: { value: loaded as LoadResult }, + }); + expect(result.current).toBe("snapshot"); + rerender({ value: transient }); + expect(result.current).toBe("snapshot"); + rerender({ value: { ...transient } }); + expect(result.current).toBe("snapshot"); + rerender({ value: permanent }); + expect(result.current).toBeUndefined(); + rerender({ value: { ...transient } }); + expect(result.current).toBeUndefined(); + }); + + it("has nothing to show when the first result fails", () => { + const { result } = renderHook(() => useLastSuccessfulData(transient)); + expect(result.current).toBeUndefined(); + }); +}); diff --git a/web/submitqueue/view/last-successful-data.ts b/web/submitqueue/view/last-successful-data.ts new file mode 100644 index 000000000..6ac7d44c9 --- /dev/null +++ b/web/submitqueue/view/last-successful-data.ts @@ -0,0 +1,22 @@ +"use client"; + +import { useState } from "react"; +import type { LoadResult } from "../entity/index.js"; + +/** + * Returns the current data, or the last successful data while the current + * result is a retryable failure. Permanent failures clear the snapshot, so key + * the caller by resource to avoid carrying data across resources. + */ +export function useLastSuccessfulData(result: LoadResult): T | undefined { + const [snapshot, setSnapshot] = useState<{ result: LoadResult; data: T | undefined }>(() => ({ + result, data: result.ok ? result.data : undefined, + })); + if (snapshot.result !== result) { + setSnapshot({ + result, + data: result.ok ? result.data : result.error.retryable ? snapshot.data : undefined, + }); + } + return result.ok ? result.data : result.error.retryable ? snapshot.data : undefined; +} diff --git a/web/submitqueue/view/navigation.tsx b/web/submitqueue/view/navigation.tsx new file mode 100644 index 000000000..180dfeaaa --- /dev/null +++ b/web/submitqueue/view/navigation.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { createContext, useContext, type AnchorHTMLAttributes, type ComponentType, type ReactNode } from "react"; + +export type WebLinkProps = AnchorHTMLAttributes & { href: string }; + +/** + * Host-supplied navigation, usually backed by the host framework's router. + * Every member is optional: with none, links and refreshes are ordinary + * document navigations and polling is manual. + */ +export interface WebNavigation { + /** Renders in-app links, e.g. a framework router link. */ + Link?: ComponentType; + /** Moves to an in-app href. */ + push?: (href: string) => void; + /** Re-renders the current page with a fresh model, or moves to `href`; settles once rendered. Enables polling. */ + refresh?: (href: string | null) => Promise; +} + +const NavigationContext = createContext({}); + +export function WebNavigationProvider({ value, children }: { value: WebNavigation; children: ReactNode }) { + return {children}; +} + +export function useWebNavigation(): WebNavigation { + return useContext(NavigationContext); +} diff --git a/web/submitqueue/view/pages.test.tsx b/web/submitqueue/view/pages.test.tsx new file mode 100644 index 000000000..bf9359b09 --- /dev/null +++ b/web/submitqueue/view/pages.test.tsx @@ -0,0 +1,90 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { WebNavigationProvider } from "./navigation.js"; +import { SubmitQueueApp } from "./app.js"; +import { SubmitQueuePage, SubmitQueueShell, SubmitQueueStatePage } from "./pages.js"; +import { createSubmitQueueWeb } from "../module.js"; +import { newHmacCursorCodec } from "../extension/cursor/hmac/index.js"; +import { createFakeGatewayReader, gatewayHistoryFixture, gatewayRequestFixture } from "../extension/gateway/mock/index.js"; +import { WebPaths } from "../core/paths.js"; +import { webErrorForGatewayCode } from "../controller/error.js"; + +const sha = "0123456789abcdef0123456789abcdef01234567"; +const uri = `github://github.com/uber/submitqueue/pull/123/${sha}`; +const gateway = createFakeGatewayReader({ + requests: [gatewayRequestFixture({ sqid: "12", changeUris: [uri] })], + history: [gatewayHistoryFixture()], +}); +const web = createSubmitQueueWeb({ + gateway, cursors: newHmacCursorCodec("host-secret"), paths: new WebPaths({ basePath: "/sq" }), +}); + +describe("default reusable application views", () => { + it("renders shared chrome with host-supplied branding, environment, and footer", () => { + render( +
Host content
+
); + expect(screen.getByRole("link", { name: "SubmitQueue queues" }).getAttribute("href")).toBe("/sq"); + expect(screen.getByText("My queue host")).toBeTruthy(); + expect(screen.getByText("Staging")).toBeTruthy(); + expect(screen.getByText("Company footer")).toBeTruthy(); + expect(screen.getByRole("main").textContent).toBe("Host content"); + }); + + it.each([ + { path: "/sq", heading: "Queues" }, + { path: "/sq/demo-queue", heading: "demo-queue" }, + { path: "/sq/demo-queue/request/12", heading: "12" }, + { path: "/sq/demo-queue/change/github/github.com/uber/submitqueue/pull/123", heading: "PR #123" }, + ])("renders a complete page at $path without Next.js", async ({ path, heading }) => { + const result = await web.handle({ path, search: {} }); + if (result.kind !== "render") { + throw new Error("Expected a rendered page"); + } + const refresh = vi.fn(async () => undefined); + render( + + ); + expect(screen.getByRole("heading", { name: heading })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + expect(refresh).toHaveBeenCalledOnce(); + const queueLinks = screen.queryAllByRole("link", { name: "demo-queue" }); + queueLinks.forEach(link => expect(link.getAttribute("href")).toBe("/sq/demo-queue")); + }); + + it("renders a page without refresh controls outside an app", async () => { + const result = await web.handle({ path: "/sq", search: {} }); + if (result.kind !== "render") { + throw new Error("Expected a rendered page"); + } + render(); + expect(screen.queryByRole("button", { name: "Refresh" })).toBeNull(); + }); + + it("keeps the queue directory visible through a transient failure", async () => { + const result = await web.handle({ path: "/sq", search: {} }); + if (result.kind !== "render" || result.model.kind !== "queues") { + throw new Error("Expected the queue directory"); + } + const { rerender } = render(); + rerender(); + expect(screen.getByText("Showing the last successful snapshot; updates are unavailable.")).toBeTruthy(); + expect(screen.getByRole("link", { name: "demo-queue" })).toBeTruthy(); + }); + + it("delegates change-version navigation through the host adapter", async () => { + const result = await web.handle({ path: "/sq/demo-queue/change/github/github.com/uber/submitqueue/pull/123", search: {} }); + if (result.kind !== "render") { + throw new Error("Expected a change page"); + } + const navigate = vi.fn(); + render(); + fireEvent.change(screen.getByRole("combobox"), { target: { value: `/sq/demo-queue/change/github/github.com/uber/submitqueue/pull/123/${sha}` } }); + expect(navigate).toHaveBeenCalledWith(`/sq/demo-queue/change/github/github.com/uber/submitqueue/pull/123/${sha}`); + }); + + it.each(["not-found", "unauthorized"] as const)("renders the shared %s state", kind => { + render(); + expect(screen.getByRole("heading", { name: kind === "not-found" ? "Request not found" : "Authentication required" })).toBeTruthy(); + }); +}); diff --git a/web/submitqueue/view/pages.tsx b/web/submitqueue/view/pages.tsx new file mode 100644 index 000000000..acb8ac46d --- /dev/null +++ b/web/submitqueue/view/pages.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { WebRefreshControl } from "./refresh-control.js"; +import { WebShell, type WebShellProps } from "./shell.js"; +import { Timestamp } from "./timestamp.js"; +import { WebLink } from "./web-app.js"; +import { ChangeSubmissions, LastSuccessfulData, QueueDirectory, RequestDetailView, RequestListView } from "./components.js"; +import type { SubmitQueuePageModel } from "../entity/index.js"; + +export type SubmitQueueShellProps = Omit; + +export function SubmitQueueShell({ brand = "SubmitQueue", ...props }: SubmitQueueShellProps) { + return ; +} + +export interface SubmitQueuePageProps { + model: SubmitQueuePageModel; +} + +/** Renders one page model; refresh and version controls appear only inside a `WebApp` such as `SubmitQueueApp`. */ +export function SubmitQueuePage({ model }: SubmitQueuePageProps) { + const refreshControl = ; + if (model.kind === "queue") { + return
+
+
+

Queue activity

{model.queue}

+

{model.paged ? "Older requests · snapshot" : "Last 24 hours · updates on refresh"}: —

+
+
Latest 24 hours{refreshControl}
+
+
+ +
+
; + } + if (model.kind === "request") { + return
+ +
; + } + return
+ {model.kind === "queues" + ? {queues => } + : {change => }} +
{refreshControl}
+
; +} + +export function SubmitQueueStatePage({ kind, homeHref = "/", message }: { + kind: "not-found" | "unauthorized"; homeHref?: string; message?: string; +}) { + const notFound = kind === "not-found"; + return +
+
+

{notFound ? "Request lookup" : "SubmitQueue"}

+

{notFound ? "Request not found" : "Authentication required"}

+

{message ?? (notFound ? "The queue or request may not exist, or its retained data may have expired." : "Use the credentials configured for this host.")}

+ {notFound ? Return to queues : null} +
+
+
; +} diff --git a/web/submitqueue/view/refresh-control.tsx b/web/submitqueue/view/refresh-control.tsx new file mode 100644 index 000000000..39a02d0dc --- /dev/null +++ b/web/submitqueue/view/refresh-control.tsx @@ -0,0 +1,15 @@ +"use client"; + +import type { WebRefreshModel } from "../entity/index.js"; +import { AutoRefresh } from "./auto-refresh.js"; +import { useWebApp } from "./web-app.js"; + +/** Refresh button and polling status for the page rendered by the enclosing `WebApp`; renders nothing outside one. */ +export function WebRefreshControl({ refresh }: { refresh: WebRefreshModel }) { + const app = useWebApp(); + if (!app) { + return null; + } + return ; +} diff --git a/web/submitqueue/view/shell.tsx b/web/submitqueue/view/shell.tsx new file mode 100644 index 000000000..ae72dff70 --- /dev/null +++ b/web/submitqueue/view/shell.tsx @@ -0,0 +1,33 @@ +"use client"; + +import type { ReactNode } from "react"; + +export interface WebShellProps { + children: ReactNode; + homeHref?: string; + /** Accessible name of the home link. */ + homeLabel?: string; + brand?: ReactNode; + /** Short monogram shown beside the brand; omitted when empty. */ + brandMark?: string; + environmentLabel?: string; + footer?: ReactNode; + /** Color scheme: `system` follows the viewer's preference. */ + theme?: "light" | "dark" | "system"; +} + +export function WebShell({ + children, homeHref = "/", homeLabel, brand, brandMark = "", environmentLabel = "Read-only", + footer = "Read-only · updates automatically", theme = "light", +}: WebShellProps) { + return
+
+ + {brandMark ? : null}{brand} + + {environmentLabel ? {environmentLabel} : null} +
+ {children} + {footer ?
{footer}
: null} +
; +} diff --git a/web/submitqueue/view/styles.css b/web/submitqueue/view/styles.css new file mode 100644 index 000000000..ea91713c7 --- /dev/null +++ b/web/submitqueue/view/styles.css @@ -0,0 +1,160 @@ +/* + * SubmitQueue web UI styles, scoped to .sq-app so unrelated host markup is + * never restyled. The --sq-* custom properties are the public theming + * contract: override them on .sq-app. + */ +.sq-app { + color-scheme: light; + --sq-surface: #fff; + --sq-surface-muted: #f5f6f8; + --sq-text: #202327; + --sq-muted: #626a74; + --sq-border: #e3e6e9; + --sq-accent: #175fc5; + --sq-accent-soft: #edf3ff; + --sq-success: #187247; + --sq-success-soft: #edf8f1; + --sq-warning: #8b590f; + --sq-warning-soft: #fff5e7; + --sq-danger: #b33535; + --sq-danger-soft: #fff0ef; + --sq-font: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --sq-font-mono: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--sq-font); +} +.sq-app[data-theme="dark"] { + color-scheme: dark; + --sq-surface: #16181b; + --sq-surface-muted: #202328; + --sq-text: #e8eaed; + --sq-muted: #a0a7b0; + --sq-border: #33373d; + --sq-accent: #6ea8ff; + --sq-accent-soft: #1c2a40; + --sq-success: #5cc28a; + --sq-success-soft: #18301f; + --sq-warning: #e0a84a; + --sq-warning-soft: #36290f; + --sq-danger: #ff7b72; + --sq-danger-soft: #3a1f1e; +} +@media (prefers-color-scheme: dark) { + .sq-app[data-theme="system"] { + color-scheme: dark; + --sq-surface: #16181b; + --sq-surface-muted: #202328; + --sq-text: #e8eaed; + --sq-muted: #a0a7b0; + --sq-border: #33373d; + --sq-accent: #6ea8ff; + --sq-accent-soft: #1c2a40; + --sq-success: #5cc28a; + --sq-success-soft: #18301f; + --sq-warning: #e0a84a; + --sq-warning-soft: #36290f; + --sq-danger: #ff7b72; + --sq-danger-soft: #3a1f1e; + } +} +.sq-app, .sq-app * { box-sizing: border-box; } +.sq-app { background: var(--sq-surface); color: var(--sq-text); font-size: 14px; line-height: 1.5; } +.sq-app a { color: var(--sq-accent); text-decoration: none; overflow-wrap: anywhere; } +.sq-app a:hover { text-decoration: underline; } +.sq-app a:focus-visible, .sq-app button:focus-visible, .sq-app input:focus-visible, .sq-app select:focus-visible, .sq-app summary:focus-visible { + outline: 3px solid var(--sq-accent); outline-offset: 3px; +} +.sq-app h1 { font-size: 26px; line-height: 1.25; margin: 0 0 10px; letter-spacing: -.5px; } +.sq-app h2 { font-size: 16px; margin: 0 0 12px; } +.sq-app p { margin: 8px 0; } +.sq-app button, .sq-app input, .sq-app select { + font: inherit; border: 1px solid var(--sq-border); border-radius: 6px; + color: var(--sq-text); background: var(--sq-surface); padding: 8px 12px; +} +.sq-app button, .sq-app summary { cursor: pointer; } +.sq-app button:disabled { cursor: default; opacity: .65; } +.sq-app button:hover:not(:disabled) { background: var(--sq-surface-muted); } +.sq-app input { min-width: 0; } +.sq-app.site-frame { min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; } +.sq-app .site-header, .sq-app .shell, .sq-app .site-footer { width: min(1200px, calc(100% - 48px)); margin-inline: auto; } +.sq-app .site-header { min-height: 80px; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--sq-border); } +.sq-app .brand { display: flex; align-items: center; gap: 9px; color: var(--sq-text); font-weight: 600; font-size: 18px; } +.sq-app .brand-mark { background: var(--sq-text); color: var(--sq-surface); border-radius: 6px; padding: 5px 7px; font-size: 12px; } +.sq-app .environment-pill, .sq-app .site-footer, .sq-app .sq-secondary { color: var(--sq-muted); font-size: 12px; } +.sq-app .site-footer { padding: 20px 0; border-top: 1px solid var(--sq-border); } +.sq-app .shell { padding-block: 28px 48px; } +.sq-app .centered-state { min-height: 100vh; display: grid; place-items: center; } +.sq-app .page-heading { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; flex-wrap: wrap; margin-bottom: 24px; } +.sq-app .page-heading p { color: var(--sq-muted); font-size: 12px; } +.sq-app .eyebrow { font-size: 12px; margin: 0 0 10px; color: var(--sq-muted); } +.sq-app .page-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 12px; } +.sq-app .sq-refresh { display: flex; align-items: center; gap: 10px; color: var(--sq-muted); font-size: 12px; } +.sq-app .sq-error { padding: 16px; border-radius: 6px; background: var(--sq-danger-soft); } +.sq-app .sq-error p { overflow-wrap: anywhere; } +.sq-app .button-link { display: inline-block; margin-top: 16px; } +.sq-app .state-card { max-width: 700px; } +@media (max-width: 680px) { +.sq-app .site-header, .sq-app .shell, .sq-app .site-footer { width: calc(100% - 32px); } +.sq-app .site-header { gap: 12px; flex-wrap: wrap; padding-block: 14px; } +.sq-app button, .sq-app select, .sq-app input { min-height: 44px; } +} + +/* SubmitQueue pages. */ +.sq-app .sq-detail-heading { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; flex-wrap: wrap; margin-bottom: 24px; } +.sq-app .sq-detail-actions, .sq-app .sq-copy { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +.sq-app .sq-detail-actions { align-items: flex-start; } +.sq-app .sq-detail-actions button { min-height: 40px; font-size: 14px; } +.sq-app .sq-copy [role="status"] { width: 100%; color: var(--sq-muted); font-size: 12px; } +.sq-app .sq-copy [role="status"]:empty { display: none; } +.sq-app .sq-toolbar { display: flex; justify-content: space-between; gap: 14px; flex-wrap: wrap; align-items: center; margin-bottom: 14px; } +.sq-app .sq-toolbar label { display: flex; align-items: center; gap: 10px; color: var(--sq-muted); font-size: 12px; flex-wrap: wrap; } +.sq-app .sq-toolbar input { width: 240px; max-width: 100%; } +.sq-app .sq-table-scroll { overflow-x: auto; } +.sq-app table { width: 100%; border-collapse: collapse; text-align: left; } +.sq-app th { font-weight: 500; color: var(--sq-muted); font-size: 12px; padding: 10px 12px; border-bottom: 1px solid var(--sq-border); } +.sq-app td { padding: 18px 12px; border-bottom: 1px solid var(--sq-border); vertical-align: top; } +.sq-app th:first-child, .sq-app td:first-child { padding-left: 0; } +.sq-app th:last-child, .sq-app td:last-child { padding-right: 0; } +.sq-app td time { font-size: 12px; color: var(--sq-muted); } +.sq-app .sq-table-footer { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 18px; font-size: 12px; color: var(--sq-muted); } +.sq-app .sq-changes { list-style: none; padding: 0; margin: 0; display: grid; gap: 8px; } +.sq-app .sq-changes code, .sq-app .sq-changes a, .sq-app .sq-version { font: 12px var(--sq-font-mono); overflow-wrap: anywhere; } +.sq-app .sq-status, .sq-app .sq-event { display: inline-block; padding: 2px 7px; border-radius: 4px; font-size: 12px; color: var(--sq-accent); background: var(--sq-accent-soft); white-space: nowrap; } +.sq-app .sq-status[data-tone="neutral"] { color: var(--sq-muted); background: var(--sq-surface-muted); } +.sq-app .sq-status[data-tone="success"] { color: var(--sq-success); background: var(--sq-success-soft); } +.sq-app .sq-status[data-tone="danger"] { color: var(--sq-danger); background: var(--sq-danger-soft); } +.sq-app .sq-status[data-tone="warning"] { color: var(--sq-warning); background: var(--sq-warning-soft); } +.sq-app .sq-row-error { color: var(--sq-danger); font-size: 12px; max-width: 36ch; overflow-wrap: anywhere; } +.sq-app .sq-empty { padding: 35px 0; color: var(--sq-muted); } +.sq-app .sq-breadcrumb { display: flex; gap: 10px; flex-wrap: wrap; color: var(--sq-muted); font-size: 12px; margin-bottom: 24px; } +.sq-app .sq-tabs { display: flex; gap: 25px; border-bottom: 1px solid var(--sq-border); margin-bottom: 24px; } +.sq-app .sq-tabs a { padding: 10px 0 12px; color: var(--sq-muted); } +.sq-app .sq-tabs [aria-current="page"] { color: var(--sq-text); border-bottom: 2px solid var(--sq-text); } +.sq-app .sq-detail-columns { display: grid; grid-template-columns: 1fr 1.15fr; gap: 38px; } +.sq-app .sq-facts { margin: 0; } +.sq-app .sq-facts > div { padding: 11px 0; border-bottom: 1px solid var(--sq-border); } +.sq-app dt { color: var(--sq-muted); font-size: 12px; margin-bottom: 5px; } +.sq-app dd { margin: 0; overflow-wrap: anywhere; } +.sq-app .sq-soft-panel { padding: 18px; background: var(--sq-surface-muted); border-radius: 8px; margin-bottom: 16px; } +.sq-app .sq-soft-panel summary { margin-bottom: 10px; } +.sq-app .sq-stage { list-style: none; display: flex; flex-wrap: wrap; gap: 9px; padding: 0; margin-top: 24px; color: var(--sq-muted); font-size: 12px; } +.sq-app .sq-stage li + li::before { content: "›"; margin-right: 9px; } +.sq-app .sq-metadata { margin: 12px 0 0; } +.sq-app .sq-metadata__entry { display: grid; grid-template-columns: minmax(100px, .35fr) 1fr; gap: 16px; font-size: 12px; padding: 7px 0; } +.sq-app .sq-history { padding: 0; list-style: none; } +.sq-app .sq-history__item { display: grid; grid-template-columns: 220px 1fr auto; gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--sq-border); } +.sq-app .sq-history__item time { font-size: 12px; color: var(--sq-muted); } +.sq-app .sq-history__item details[open] .sq-metadata { background: var(--sq-surface-muted); padding: 12px; border-radius: 6px; } +.sq-app .sq-history__item .sq-row-error { grid-column: 2; } +.sq-app .sq-failure-banner { padding: 16px; border-radius: 6px; color: var(--sq-danger); background: var(--sq-danger-soft); } +.sq-app .sq-directory ul { list-style: none; padding: 0; margin-top: 24px; } +.sq-app .sq-directory li { padding: 18px 0; border-bottom: 1px solid var(--sq-border); } +.sq-app .sq-directory li p { color: var(--sq-muted); } +.sq-app .sq-change-identity { display: flex; gap: 28px; flex-wrap: wrap; padding: 18px; border-radius: 8px; background: var(--sq-surface-muted); margin: 24px 0; } +@media (max-width: 680px) { +.sq-app .sq-detail-columns { grid-template-columns: 1fr; gap: 20px; } +.sq-app .sq-history__item { grid-template-columns: 1fr; gap: 8px; } +.sq-app .sq-history__item .sq-row-error { grid-column: 1; } +.sq-app .sq-metadata__entry { grid-template-columns: 1fr; gap: 4px; } +.sq-app .sq-toolbar label { align-items: flex-start; } +.sq-app .sq-detail-actions button { min-height: 44px; } +} diff --git a/web/submitqueue/view/timestamp.tsx b/web/submitqueue/view/timestamp.tsx new file mode 100644 index 000000000..685cee81f --- /dev/null +++ b/web/submitqueue/view/timestamp.tsx @@ -0,0 +1,11 @@ +"use client"; + +const timestampFormatter = new Intl.DateTimeFormat("en-US", { + dateStyle: "medium", timeStyle: "long", timeZone: "UTC", +}); + +export function Timestamp({ value }: { value: number }) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? : + ; +} diff --git a/web/submitqueue/view/web-app.test.tsx b/web/submitqueue/view/web-app.test.tsx new file mode 100644 index 000000000..44feb5906 --- /dev/null +++ b/web/submitqueue/view/web-app.test.tsx @@ -0,0 +1,79 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import type { WebPageModel } from "../entity/index.js"; +import { WebNavigationProvider, type WebNavigation } from "./navigation.js"; +import { WebRefreshControl } from "./refresh-control.js"; +import { useWebApp, WebApp, WebLink } from "./web-app.js"; + +function pageModel(title: string, refreshHref: string | null = null): WebPageModel { + return { key: title, title, refresh: { terminal: false, transientFailureCount: 0, refreshHref } }; +} + +function Page({ model }: { model: WebPageModel }) { + const app = useWebApp(); + return
+

{model.title}

+ + +
; +} + +function renderApp(model: WebPageModel, navigation: WebNavigation) { + return render(); +} + +describe("WebApp", () => { + it("refreshes through the host, moving a snapshot page to its refresh target", () => { + const refresh = vi.fn(async () => undefined); + renderApp(pageModel("Snapshot", "/latest"), { refresh }); + + fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + + expect(refresh).toHaveBeenCalledWith("/latest"); + }); + + it("navigates through the host router", () => { + const push = vi.fn(); + renderApp(pageModel("First"), { push }); + + fireEvent.click(screen.getByRole("button", { name: "Go" })); + + expect(push).toHaveBeenCalledWith("/next"); + }); + + it("renders whatever model the host passes next", () => { + const navigation = {}; + const { rerender } = renderApp(pageModel("First"), navigation); + + rerender(); + + expect(screen.getByRole("heading").textContent).toBe("Second"); + }); + + it("offers only manual refresh when the host cannot refresh in place", () => { + renderApp(pageModel("First"), {}); + expect(screen.getByText("Manual refresh")).toBeDefined(); + }); +}); + +describe("WebLink", () => { + it("renders the host link component when one is supplied", () => { + const Link = ({ href, children }: { href: string; children?: ReactNode }) => {children}; + render(Next); + expect(screen.getByRole("link").hasAttribute("data-host-link")).toBe(true); + }); + + it("renders a plain anchor otherwise", () => { + render(Next); + expect(screen.getByRole("link").getAttribute("href")).toBe("/next"); + }); +}); + +describe("WebRefreshControl", () => { + it("renders nothing outside a WebApp", () => { + const { container } = render(); + expect(container.innerHTML).toBe(""); + }); +}); diff --git a/web/submitqueue/view/web-app.tsx b/web/submitqueue/view/web-app.tsx new file mode 100644 index 000000000..5dc48a6b2 --- /dev/null +++ b/web/submitqueue/view/web-app.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { createContext, useContext, useMemo, useRef, type ComponentType } from "react"; +import type { WebPageModel } from "../entity/index.js"; +import { useWebNavigation, type WebLinkProps } from "./navigation.js"; + +/** Client-side controls of the enclosing {@link WebApp}. */ +export interface WebAppControls { + /** Moves to an in-app href. */ + navigate(href: string): Promise; + /** Refreshes the current page; rejects when the refresh fails. */ + refresh(): Promise; + /** Whether the host refreshes without a document reload, so polling is worthwhile. */ + readonly automaticRefresh: boolean; +} + +const AppContext = createContext(null); + +export function useWebApp(): WebAppControls | null { + return useContext(AppContext); +} + +export interface WebAppProps { + model: Model; + Page: ComponentType<{ model: Model }>; +} + +/** + * Renders the host's current page model and gives its components navigation + * and refresh through the host's `WebNavigationProvider`. The host owns the + * model: a refresh or navigation re-renders `WebApp` with a new one. + */ +export function WebApp({ model, Page }: WebAppProps) { + const navigation = useWebNavigation(); + const modelRef = useRef(model); + modelRef.current = model; + + const controls = useMemo(() => ({ + automaticRefresh: navigation.refresh !== undefined, + async navigate(href) { + if (navigation.push) { + navigation.push(href); + } else { + window.location.assign(href); + } + }, + async refresh() { + const refreshHref = modelRef.current.refresh.refreshHref; + if (navigation.refresh) { + await navigation.refresh(refreshHref); + } else { + window.location.assign(refreshHref ?? `${window.location.pathname}${window.location.search}`); + } + }, + }), [navigation]); + + return ; +} + +/** An in-app link: the host's `Link` when supplied, otherwise a plain anchor. */ +export function WebLink(props: WebLinkProps) { + const { Link } = useWebNavigation(); + return Link ? : ; +} diff --git a/web/submitqueue/vitest.config.ts b/web/submitqueue/vitest.config.ts new file mode 100644 index 000000000..21c8ec048 --- /dev/null +++ b/web/submitqueue/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + esbuild: { + jsx: "automatic", + }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./test-setup.ts"], + }, +}); diff --git a/web/test/README.md b/web/test/README.md new file mode 100644 index 000000000..6bbf04c98 --- /dev/null +++ b/web/test/README.md @@ -0,0 +1,6 @@ +# Web tests + +- [`e2e/`](e2e/): the real stack (Bazel-built Go services, web image, MySQL) in Docker, driven by Playwright with axe checks. `make web-e2e-test`; set `SKIP_CLEANUP=true` to keep the stack for debugging. +- [`package/`](package/): checks the built packages as a consumer sees them: tarball contents and import boundaries, a typecheck and a plain-Node render through `createSubmitQueueWeb`, and React 18 render and hydration in `react18/`. + +Unit tests live next to the code they cover. diff --git a/web/test/e2e/BUILD.bazel b/web/test/e2e/BUILD.bazel new file mode 100644 index 000000000..927cc2071 --- /dev/null +++ b/web/test/e2e/BUILD.bazel @@ -0,0 +1,200 @@ +load("@npm//web:@playwright/test/package_json.bzl", playwright = "bin") +load("@bazel_lib//lib:copy_to_bin.bzl", "copy_to_bin") +load("@aspect_rules_js//npm:defs.bzl", "npm_link_package") +load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load") +load("@rules_pkg//pkg:mappings.bzl", "pkg_attributes", "pkg_files") +load("@rules_pkg//pkg:tar.bzl", "pkg_tar") +load("@rules_python//python:defs.bzl", "py_library", "py_test") + +package(default_visibility = ["//visibility:public"]) + +filegroup( + name = "srcs", + srcs = ["submitqueue.spec.ts"], +) + +copy_to_bin( + name = "srcs_for_typecheck", + srcs = [":srcs"], +) + +playwright.playwright_binary(name = "playwright") + +npm_link_package( + name = "node_modules/@submitqueue/api", + src = "//web/api:pkg", +) + +pkg_files( + name = "gateway_binary", + srcs = ["//service/submitqueue/gateway/server:gateway_linux"], + attributes = pkg_attributes(mode = "0555"), + prefix = "app", + renames = { + "//service/submitqueue/gateway/server:gateway_linux": "gateway", + }, +) + +pkg_files( + name = "gateway_configuration", + srcs = ["//service/submitqueue/gateway/server:queues.yaml"], + attributes = pkg_attributes(mode = "0444"), + prefix = "app", + renames = { + "//service/submitqueue/gateway/server:queues.yaml": "queues.yaml", + }, +) + +pkg_tar( + name = "gateway_layer", + srcs = [ + ":gateway_binary", + ":gateway_configuration", + ], +) + +oci_image( + name = "gateway_image", + architecture = "amd64", + entrypoint = ["/app/gateway"], + os = "linux", + tars = [":gateway_layer"], + workdir = "/app", +) + +oci_load( + name = "gateway_image_load", + image = ":gateway_image", + repo_tags = ["submitqueue-web-e2e-gateway-service:latest"], +) + +pkg_files( + name = "orchestrator_binary", + srcs = ["//service/submitqueue/orchestrator/server:orchestrator_linux"], + attributes = pkg_attributes(mode = "0555"), + prefix = "app", + renames = { + "//service/submitqueue/orchestrator/server:orchestrator_linux": "orchestrator", + }, +) + +pkg_tar( + name = "orchestrator_layer", + srcs = [":orchestrator_binary"], +) + +oci_image( + name = "orchestrator_image", + architecture = "amd64", + entrypoint = ["/app/orchestrator"], + os = "linux", + tars = [":orchestrator_layer"], + workdir = "/app", +) + +oci_load( + name = "orchestrator_image_load", + image = ":orchestrator_image", + repo_tags = ["submitqueue-web-e2e-orchestrator-service:latest"], +) + +pkg_files( + name = "runway_binary", + srcs = ["//service/runway/server:runway_linux"], + attributes = pkg_attributes(mode = "0555"), + prefix = "app", + renames = { + "//service/runway/server:runway_linux": "runway", + }, +) + +pkg_tar( + name = "runway_layer", + srcs = [":runway_binary"], +) + +oci_image( + name = "runway_image", + architecture = "amd64", + entrypoint = ["/app/runway"], + os = "linux", + tars = [":runway_layer"], + workdir = "/app", +) + +oci_load( + name = "runway_image_load", + image = ":runway_image", + repo_tags = ["submitqueue-web-e2e-runway-service:latest"], +) + +oci_load( + name = "mysql_image_load", + image = "@mysql_8_0_linux_amd64", + repo_tags = ["submitqueue-web-e2e-mysql:8.0"], +) + +py_library( + name = "run_e2e_lib", + srcs = ["run_e2e.py"], +) + +py_test( + name = "run_e2e_test", + srcs = ["run_e2e_test.py"], + deps = [":run_e2e_lib"], + legacy_create_init = 0, + main = "run_e2e_test.py", +) + +py_test( + name = "web_test", + legacy_create_init = 0, + size = "enormous", + srcs = ["run_e2e_main.py"], + deps = [":run_e2e_lib"], + main = "run_e2e_main.py", + args = [ + "$(rootpath :gateway_image_load)", + "$(rootpath :orchestrator_image_load)", + "$(rootpath :runway_image_load)", + "$(rootpath :mysql_image_load)", + "$(rootpath //web/service/submitqueue:image_load)", + "$(rootpath :playwright)", + "$(rootpath @playwright//:chromium)", + ], + data = [ + ":playwright", + ":srcs", + "//platform/extension/counter/mysql/schema", + "//platform/extension/messagequeue/mysql/schema", + "//service/submitqueue:docker-compose.fake.yml", + "//service/submitqueue:docker-compose.web.yml", + "//service/submitqueue:docker-compose.yml", + "//service/submitqueue/demo:fake_provider_config", + "//submitqueue/gateway/extension/storage/mysql/schema", + "//submitqueue/orchestrator/extension/storage/mysql/schema", + "//web:node_modules/@axe-core/playwright", + "//web:node_modules/@bufbuild/protobuf", + "//web:node_modules/@connectrpc/connect", + "//web:node_modules/@connectrpc/connect-node", + "//web:node_modules/@playwright/test", + ":node_modules/@submitqueue/api", + "//web:package.json", + "//web:playwright.config.ts", + "//web/api:lib", + "//web/api:lib_types", + "//web/service/submitqueue:image_load", + ":docker-compose.e2e.yml", + ":gateway_image_load", + ":mysql_image_load", + ":orchestrator_image_load", + ":runway_image_load", + "@playwright//:chromium", + ], + tags = [ + "e2e", + "integration", + "requires-network", + ], +) diff --git a/web/test/e2e/docker-compose.e2e.yml b/web/test/e2e/docker-compose.e2e.yml new file mode 100644 index 000000000..ab7436cb6 --- /dev/null +++ b/web/test/e2e/docker-compose.e2e.yml @@ -0,0 +1,17 @@ +services: + mysql-app: + image: ${SQ_DOCKER_IMAGE_PREFIX:-submitqueue-web-e2e}-mysql:8.0 + platform: linux/amd64 + + mysql-queue: + image: ${SQ_DOCKER_IMAGE_PREFIX:-submitqueue-web-e2e}-mysql:8.0 + platform: linux/amd64 + + gateway-service: + platform: linux/amd64 + + orchestrator-service: + platform: linux/amd64 + + runway-service: + platform: linux/amd64 diff --git a/web/test/e2e/run_e2e.py b/web/test/e2e/run_e2e.py new file mode 100644 index 000000000..a94f59c59 --- /dev/null +++ b/web/test/e2e/run_e2e.py @@ -0,0 +1,292 @@ +import fcntl +import os +import shutil +import signal +import subprocess +import sys +import tempfile +from pathlib import Path + + +_PROJECT = f"e2e-submitqueue-web-{os.getpid()}" +_TOKEN = "test" + + +def _runfile(path: str) -> Path: + runfiles_dir = os.environ.get("RUNFILES_DIR") + if runfiles_dir: + return Path(runfiles_dir) / "_main" / path + + manifest_path = os.environ.get("RUNFILES_MANIFEST_FILE") + if not manifest_path: + raise RuntimeError("Bazel runfiles are unavailable") + + key = f"_main/{path}" + with open(manifest_path, encoding="utf-8") as manifest: + for line in manifest: + logical, separator, physical = line.rstrip("\n").partition(" ") + if logical == key: + return Path(physical if separator else logical) + raise RuntimeError(f"Bazel runfile not found: {path}") + + +def _copy(source: Path, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, destination) + + +def _link(source: Path, destination: Path) -> None: + destination.parent.mkdir(parents=True, exist_ok=True) + destination.symlink_to(source, target_is_directory=True) + + +def _run(command: list[str], *, env: dict[str, str], stdin=None, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, check=check, env=env, stdin=stdin, text=True) + + +def _output(command: list[str], *, env: dict[str, str]) -> str: + return subprocess.check_output(command, env=env, text=True).strip() + + +def _published_port(container: str, container_port: str, *, env: dict[str, str]) -> str: + address = _output(["docker", "port", container, container_port], env=env) + return address.splitlines()[0].rsplit(":", 1)[-1] + + +def _stage_provider_configuration(workspace: Path, context: Path) -> None: + copies = { + "service/submitqueue/demo/provider/fake/merge.yaml": "provider/merge.yaml", + "service/submitqueue/demo/provider/fake/profiles.yaml": "provider/profiles.yaml", + } + for source, destination in copies.items(): + _copy(workspace / source, context / destination) + + +def _stage_playwright(workspace: Path, context: Path) -> Path: + playwright_root = context / "playwright" + _copy(workspace / "web/package.json", playwright_root / "package.json") + _copy(workspace / "web/playwright.config.ts", playwright_root / "playwright.config.ts") + _copy(workspace / "web/test/e2e/submitqueue.spec.ts", playwright_root / "test/e2e/submitqueue.spec.ts") + + links = { + "web/node_modules/@axe-core/playwright": "node_modules/@axe-core/playwright", + "web/node_modules/@bufbuild/protobuf": "node_modules/@bufbuild/protobuf", + "web/node_modules/@connectrpc/connect": "node_modules/@connectrpc/connect", + "web/node_modules/@connectrpc/connect-node": "node_modules/@connectrpc/connect-node", + "web/node_modules/@playwright/test": "node_modules/@playwright/test", + "web/test/e2e/node_modules/@submitqueue/api": "node_modules/@submitqueue/api", + } + for source, destination in links.items(): + _link(workspace / source, playwright_root / destination) + return playwright_root + + +def _stage_docker_config(destination: Path) -> None: + destination.mkdir() + source = Path.home() / ".docker" + config = source / "config.json" + if config.is_file(): + _copy(config, destination / "config.json") + contexts = source / "contexts" + if contexts.is_dir(): + shutil.copytree(contexts, destination / "contexts", symlinks=True) + # Docker finds per-user CLI plugins such as Compose through DOCKER_CONFIG. + plugins = source / "cli-plugins" + if plugins.is_dir(): + (destination / "cli-plugins").symlink_to(plugins, target_is_directory=True) + + +def _compose_command(*, env: dict[str, str]) -> list[str]: + # Mirrors test/testutil/compose.go: prefer the Compose plugin, which CI runners ship, + # and fall back to the standalone binary. + if shutil.which("docker", path=env.get("PATH")) is None: + return ["docker-compose"] + probe = subprocess.run( + ["docker", "compose", "version"], + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return ["docker", "compose"] if probe.returncode == 0 else ["docker-compose"] + + +def _load_project_images(image_loaders: list[Path], *, env: dict[str, str]) -> None: + lock_path = Path(tempfile.gettempdir()) / "submitqueue-web-e2e-image.lock" + with lock_path.open("w", encoding="utf-8") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + for image_loader in image_loaders: + _run([str(image_loader)], env=env) + _run( + [ + "docker", + "tag", + "submitqueue-web-e2e-mysql:8.0", + f"{_PROJECT}-mysql:8.0", + ], + env=env, + ) + for service in ("gateway", "orchestrator", "runway", "web"): + _run( + [ + "docker", + "tag", + f"submitqueue-web-e2e-{service}-service:latest" + if service != "web" + else "submitqueue-web-service:latest", + f"{_PROJECT}-{service}-service:latest", + ], + env=env, + ) + + +def _apply_schemas(workspace: Path, *, env: dict[str, str]) -> None: + schema_groups = ( + (workspace / "submitqueue/gateway/extension/storage/mysql/schema", f"{_PROJECT}-mysql-app-1"), + (workspace / "submitqueue/orchestrator/extension/storage/mysql/schema", f"{_PROJECT}-mysql-app-1"), + (workspace / "platform/extension/counter/mysql/schema", f"{_PROJECT}-mysql-app-1"), + (workspace / "platform/extension/messagequeue/mysql/schema", f"{_PROJECT}-mysql-queue-1"), + ) + for schema_dir, container in schema_groups: + for schema in sorted(schema_dir.glob("*.sql")): + with schema.open("r", encoding="utf-8") as statements: + _run( + ["docker", "exec", "-i", container, "mysql", "-uroot", "-proot", "submitqueue"], + env=env, + stdin=statements, + ) + + +def _chromium_executable(chromium: Path) -> Path: + candidates = ( + chromium / "chrome-linux/chrome", + chromium / "chrome-mac/Chromium.app/Contents/MacOS/Chromium", + ) + for candidate in candidates: + if candidate.is_file() and os.access(candidate, os.X_OK): + return candidate + raise RuntimeError(f"unable to find the Bazel-managed Chromium executable under {chromium}") + + +def _capture_compose_logs(compose: list[str], *, env: dict[str, str]) -> None: + try: + result = subprocess.run( + [*compose, "logs", "--no-color"], + check=False, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + except OSError as cause: + print(f"Unable to collect Compose logs: {cause}", file=sys.stderr) + return + sys.stderr.write(result.stdout) + output_dir = env.get("TEST_UNDECLARED_OUTPUTS_DIR") + if output_dir: + destination = Path(output_dir) / "compose.log" + try: + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(result.stdout, encoding="utf-8") + except OSError as cause: + print(f"Unable to preserve Compose logs at {destination}: {cause}", file=sys.stderr) + + +def main() -> int: + if len(sys.argv) != 8: + raise RuntimeError("expected five image loaders, Playwright, and Chromium runfile paths") + + image_loaders = [_runfile(path) for path in sys.argv[1:6]] + playwright = _runfile(sys.argv[6]) + chromium = _runfile(sys.argv[7]) + workspace = _runfile("web/package.json").parents[1] + context = Path(tempfile.mkdtemp(prefix="submitqueue-web-e2e.")) + gate_dir = context / "consumer-gate" + gate_dir.mkdir() + + env = os.environ.copy() + docker_config = context / "docker-config" + _stage_docker_config(docker_config) + env["DOCKER_CONFIG"] = str(docker_config) + + compose = [ + *_compose_command(env=env), + "-f", + str(workspace / "service/submitqueue/docker-compose.yml"), + "-f", + str(workspace / "service/submitqueue/docker-compose.fake.yml"), + "-f", + str(workspace / "service/submitqueue/docker-compose.web.yml"), + "-f", + str(workspace / "web/test/e2e/docker-compose.e2e.yml"), + "-p", + _PROJECT, + ] + + def handle_signal(signum, _frame): + raise SystemExit(128 + signum) + + signal.signal(signal.SIGTERM, handle_signal) + signal.signal(signal.SIGINT, handle_signal) + + try: + _stage_provider_configuration(workspace, context) + playwright_root = _stage_playwright(workspace, context) + _load_project_images(image_loaders, env=env) + + env.update( + { + "REPO_ROOT": str(context), + "SQ_PROVIDER_CONFIG_DIR": str(context / "provider"), + "SQ_CONSUMER_GATE_DIR": str(gate_dir), + "SQ_DOCKER_IMAGE_PREFIX": _PROJECT, + "SUBMITQUEUE_WEB_TOKEN": _TOKEN, + "SUBMITQUEUE_WEB_CURSOR_SECRET": "e2e-cursor-secret", + "SQ_MYSQL_INITDB_SKIP_TZINFO": "1", + } + ) + security_options = _output(["docker", "info", "--format", "{{json .SecurityOptions}}"], env=env) + env["SQ_CONTAINER_USER"] = "0:0" if "name=rootless" in security_options else f"{os.getuid()}:{os.getgid()}" + + _run([*compose, "up", "-d", "--no-build", "--pull", "never", "--wait"], env=env) + _apply_schemas(workspace, env=env) + + gateway_port = _published_port(f"{_PROJECT}-gateway-service-1", "8080/tcp", env=env) + web_port = _published_port(f"{_PROJECT}-web-service-1", "3000/tcp", env=env) + env.update( + { + "SUBMITQUEUE_E2E_GATEWAY_URL": f"http://127.0.0.1:{gateway_port}", + "SUBMITQUEUE_E2E_WEB_URL": f"http://127.0.0.1:{web_port}", + "PLAYWRIGHT_OUTPUT_DIR": str(Path(env.get("TEST_UNDECLARED_OUTPUTS_DIR", context)) / "playwright"), + "PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH": str(_chromium_executable(chromium)), + "JS_BINARY__PATCH_NODE_FS": "0", + } + ) + _run([str(playwright), "test", "--config", str(playwright_root / "playwright.config.ts")], env=env) + return 0 + except BaseException: + _capture_compose_logs(compose, env=env) + raise + finally: + if env.get("SKIP_CLEANUP") != "true": + _run([*compose, "down", "-v", "--remove-orphans"], env=env, check=False) + _run( + [ + "docker", + "image", + "rm", + "-f", + f"{_PROJECT}-gateway-service:latest", + f"{_PROJECT}-orchestrator-service:latest", + f"{_PROJECT}-runway-service:latest", + f"{_PROJECT}-web-service:latest", + f"{_PROJECT}-mysql:8.0", + ], + env=env, + check=False, + ) + shutil.rmtree(context, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/test/e2e/run_e2e_main.py b/web/test/e2e/run_e2e_main.py new file mode 100644 index 000000000..db95a6eed --- /dev/null +++ b/web/test/e2e/run_e2e_main.py @@ -0,0 +1,5 @@ +from web.test.e2e.run_e2e import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/test/e2e/run_e2e_test.py b/web/test/e2e/run_e2e_test.py new file mode 100644 index 000000000..efa47e49d --- /dev/null +++ b/web/test/e2e/run_e2e_test.py @@ -0,0 +1,49 @@ +import io +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from web.test.e2e.run_e2e import _capture_compose_logs, _compose_command + + +class CaptureComposeLogsTest(unittest.TestCase): + def test_writes_failure_output_and_bazel_artifact(self) -> None: + with tempfile.TemporaryDirectory() as output_dir: + stderr = io.StringIO() + result = subprocess.CompletedProcess( + args=["docker-compose", "logs"], + returncode=0, + stdout="web-service | gateway readiness check failed\n", + ) + + with patch("web.test.e2e.run_e2e.subprocess.run", return_value=result), patch( + "web.test.e2e.run_e2e.sys.stderr", stderr + ): + _capture_compose_logs( + ["docker-compose", "-p", "e2e-submitqueue-web-test"], + env={"TEST_UNDECLARED_OUTPUTS_DIR": output_dir}, + ) + + expected = "web-service | gateway readiness check failed\n" + self.assertEqual(stderr.getvalue(), expected) + self.assertEqual((Path(output_dir) / "compose.log").read_text(), expected) + + +class ComposeCommandTest(unittest.TestCase): + def test_prefers_the_compose_plugin(self) -> None: + with patch("web.test.e2e.run_e2e.shutil.which", return_value="/usr/bin/docker"), patch( + "web.test.e2e.run_e2e.subprocess.run", return_value=subprocess.CompletedProcess(args=[], returncode=0) + ): + self.assertEqual(_compose_command(env={}), ["docker", "compose"]) + + def test_falls_back_to_the_standalone_binary(self) -> None: + with patch("web.test.e2e.run_e2e.shutil.which", return_value="/usr/bin/docker"), patch( + "web.test.e2e.run_e2e.subprocess.run", return_value=subprocess.CompletedProcess(args=[], returncode=1) + ): + self.assertEqual(_compose_command(env={}), ["docker-compose"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/test/e2e/submitqueue.spec.ts b/web/test/e2e/submitqueue.spec.ts new file mode 100644 index 000000000..4c83fef8f --- /dev/null +++ b/web/test/e2e/submitqueue.spec.ts @@ -0,0 +1,249 @@ +import AxeBuilder from "@axe-core/playwright"; +import { createClient } from "@connectrpc/connect"; +import { createGrpcTransport } from "@connectrpc/connect-node"; +import { expect, test } from "@playwright/test"; + +import { Strategy } from "@submitqueue/api/base/mergestrategy"; +import { SubmitQueueGateway } from "@submitqueue/api/submitqueue/gateway"; + +const queue = "demo-queue"; +const gatewayURL = process.env.SUBMITQUEUE_E2E_GATEWAY_URL as string; +const webURL = process.env.SUBMITQUEUE_E2E_WEB_URL as string; + +const gateway = createClient( + SubmitQueueGateway, + createGrpcTransport({ baseUrl: gatewayURL }), +); + +async function submitRequest(uri: string): Promise { + const response = await gateway.land({ + queue, + change: { uris: [uri] }, + strategy: Strategy.SQUASH_REBASE, + }); + expect(response.sqid).not.toBe(""); + await expect + .poll( + async () => { + const summary = await gateway.getRequestSummaryByID({ + queue, + sqid: response.sqid, + }); + return summary.request?.status; + }, + { timeout: 0 }, + ) + .toBe("landed"); + return response.sqid; +} + +function expectOrderedHistory(values: readonly string[], expected: readonly string[]): void { + let position = 0; + for (const value of values) { + if (value.includes(expected[position] ?? "\u0000")) { + position += 1; + } + } + expect(position).toBe(expected.length); +} + +test("challenges anonymous requests", async () => { + const response = await fetch(new URL(`/${queue}`, webURL), { + redirect: "manual", + }); + expect(response.status).toBe(401); + expect(response.headers.get("www-authenticate")).toContain("Basic"); +}); + +test("discovers gateway queues including empty queues and rejects unknown queues", async ({ page }) => { + const expected = (await gateway.listQueues({})).queues.map(value => value.name); + expect(expected).toContain(queue); + const emptyQueue = expected.find(name => name !== queue)!; + expect(emptyQueue).toBeDefined(); + await page.goto("/"); + await expect(page.locator(".sq-directory h2 a")).toHaveText(expected); + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + await page.getByRole("link", { name: emptyQueue, exact: true }).click(); + await expect(page.getByRole("heading", { name: emptyQueue, exact: true })).toBeVisible(); + await expect(page.getByText("No requests were received in this window.")).toBeVisible(); + await page.goto(`/${emptyQueue}/request/999999`); + await expect(page.getByRole("heading", { name: "Request not found", exact: true })).toBeVisible(); + await page.goto("/not-a-configured-queue"); + await expect(page.getByRole("heading", { name: "Request not found", exact: true })).toBeVisible(); +}); + +test("shows newest requests and an accessible readable detail history", async ({ page }) => { + await page.goto("/"); + await expect(page.getByRole("heading", { name: "Queues", exact: true })).toBeVisible(); + await page.getByRole("link", { name: queue, exact: true }).click(); + await expect(page.getByText("No requests were received in this window.")).toBeVisible(); + expect(new URL(page.url()).search).toBe(""); + + const firstSqid = await submitRequest( + "github://github.com/uber/submitqueue/pull/123/1111111111111111111111111111111111111111", + ); + const secondSqid = await submitRequest( + "github://github.com/uber/submitqueue/pull/123/2222222222222222222222222222222222222222", + ); + const repeatedSqid = await submitRequest( + "github://github.com/uber/submitqueue/pull/123/2222222222222222222222222222222222222222", + ); + + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + const requestLinks = page.locator(".sq-request-list__item td:first-child a"); + await expect(requestLinks).toHaveCount(3, { timeout: 0 }); + await expect(requestLinks.nth(0)).toHaveText(repeatedSqid); + await expect(requestLinks.nth(1)).toHaveText(secondSqid); + await expect(requestLinks.nth(2)).toHaveText(firstSqid); + expect(new URL(page.url()).search).toBe(""); + + const listAccessibility = await new AxeBuilder({ page }).analyze(); + expect(listAccessibility.violations).toEqual([]); + await page.screenshot({ path: test.info().outputPath("queue.png"), fullPage: true }); + + await page.getByRole("searchbox").fill("1111111111111111111111111111111111111111"); + await expect(requestLinks).toHaveCount(1); + await page.getByRole("searchbox").fill(""); + + await requestLinks.nth(0).click(); + await expect(page.getByRole("heading", { name: repeatedSqid, exact: true })).toBeVisible(); + await expect(page.locator(".sq-request-detail .sq-status").first()).toHaveText("Landed"); + + expect(new URL(page.url()).pathname).toBe( + `/${queue}/request/${repeatedSqid}`, + ); + + await expect(page.getByRole("button", { name: "Copy ID" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Summary", exact: true })).toHaveAttribute("aria-current", "page"); + const actionBoxes = await Promise.all(["Copy ID", "Copy link", "Refresh"].map(name => + page.getByRole("button", { name, exact: true }).boundingBox())); + expect(actionBoxes.every(box => box !== null)).toBe(true); + expect(actionBoxes.map(box => box!.y)).toEqual([actionBoxes[0]!.y, actionBoxes[0]!.y, actionBoxes[0]!.y]); + expect(actionBoxes.map(box => box!.height)).toEqual([actionBoxes[0]!.height, actionBoxes[0]!.height, actionBoxes[0]!.height]); + await page.screenshot({ path: test.info().outputPath("request-summary.png"), fullPage: true }); + await page.getByRole("link", { name: /^History/ }).click(); + await expect(page).toHaveURL(url => url.searchParams.get("view") === "history"); + await expect(page.getByRole("list", { name: "Request history" })).toBeVisible(); + + const history = await page.locator(".sq-history__item").allTextContents(); + expectOrderedHistory(history, [ + "Accepted", + "Started", + "Validating", + "Batched", + "Speculating", + "Building", + "Built", + "Speculated", + "Landing", + "Landed", + ]); + + const detailAccessibility = await new AxeBuilder({ page }).analyze(); + expect(detailAccessibility.violations).toEqual([]); + await page.screenshot({ path: test.info().outputPath("request-history.png"), fullPage: true }); + + await page.getByRole("combobox").selectOption("event"); + await expect(page.getByRole("list", { name: "Request history" }).getByText("Accepted")).toHaveCount(0); + await page.getByRole("link", { name: "Summary", exact: true }).click(); + await page.getByRole("link", { name: "github://github.com/uber/submitqueue/pull/123/2222222222222222222222222222222222222222", exact: true }).click(); + await expect(page.getByRole("heading", { name: "PR #123" })).toBeVisible(); + await expect(page.getByRole("table", { name: "Change submissions" }).locator("tbody tr")).toHaveCount(3); + expect(new URL(page.url()).search).toBe(""); + await page.screenshot({ path: test.info().outputPath("change-submissions.png"), fullPage: true }); + await page.getByRole("combobox", { name: "Filter submissions by version" }).selectOption({ label: "2222222222222222222222222222222222222222" }); + await expect(page.getByText("Retained submissions for this exact version")).toBeVisible(); + await expect(page.getByRole("table", { name: "Change submissions" }).locator("tbody tr")).toHaveCount(2); + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + + await page.goto(`/${queue}?from=1&to=2`); + await expect(page.getByRole("heading", { name: queue, exact: true })).toBeVisible(); + expect(new URL(page.url()).search).toBe(""); + const gitUri = "git://git.example.com/demo/refs%2Fheads%2Fweb-demo/3333333333333333333333333333333333333333"; + const gitRequest = await submitRequest(`${gitUri}?sq-files=demo%2F00%2Ffile.txt`); + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await expect(page.getByRole("link", { name: gitRequest, exact: true })).toBeVisible(); + const changeLink = page.getByRole("link", { name: gitUri, exact: true }); + await expect(changeLink).toBeVisible(); + await expect(changeLink).toHaveAttribute("href", `/${queue}/change/git/git.example.com/demo/refs%2Fheads%2Fweb-demo`); + await expect(page.getByText(/sq-files=/)).toHaveCount(0); + await page.screenshot({ path: test.info().outputPath("queue-live.png"), fullPage: true }); + await changeLink.click(); + await expect(page.getByRole("heading", { name: "refs/heads/web-demo", exact: true })).toBeVisible(); + await expect(page.getByRole("table", { name: "Change submissions" }).locator("tbody tr")).toHaveCount(1); + expect(new URL(page.url()).search).toBe(""); + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + await page.screenshot({ path: test.info().outputPath("git-change.png"), fullPage: true }); + const logicalChangeUrl = page.url(); + await page.reload(); + await expect(page.getByRole("heading", { name: "refs/heads/web-demo", exact: true })).toBeVisible(); + await page.getByRole("combobox", { name: "Filter submissions by version" }).selectOption({ + label: "3333333333333333333333333333333333333333", + }); + await expect(page.getByRole("heading", { name: "refs/heads/web-demo", exact: true })).toBeVisible(); + await expect(page.getByRole("table", { name: "Change submissions" }).locator("tbody tr")).toHaveCount(1); + await page.goto(logicalChangeUrl); + await expect(page.getByRole("heading", { name: "refs/heads/web-demo", exact: true })).toBeVisible(); +}); + +test("preserves literal percent escapes in Git refs when opening change history", async ({ page }) => { + const uri = "git://git.example.com/demo/refs%2Fheads%2Fweb-demo%252Fpercent/4444444444444444444444444444444444444444"; + const sqid = await submitRequest(`${uri}?sq-files=demo%2F00%2Fpercent.txt`); + await page.goto(`/${queue}`); + await page.getByRole("link", { name: uri, exact: true }).click(); + await expect(page.getByRole("heading", { name: "refs/heads/web-demo%2Fpercent", exact: true })).toBeVisible(); + await expect(page.getByRole("table", { name: "Change submissions" }).getByRole("link", { name: sqid, exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByRole("heading", { name: "refs/heads/web-demo%2Fpercent", exact: true })).toBeVisible(); +}); + +test("paginates change history through more than 500 queue requests", async ({ page }) => { + const changePath = `/${queue}/change/git/git.example.com/demo/refs%2Fheads%2Fpagination`; + const oldest = await submitRequest("git://git.example.com/demo/refs%2Fheads%2Fpagination/5555555555555555555555555555555555555555"); + const expectedIds = [oldest]; + for (let offset = 0; offset < 501; offset += 25) { + const replies = await Promise.all(Array.from({ length: Math.min(25, 501 - offset) }, (_, index) => gateway.land({ + queue, + change: { uris: [`github://github.com/uber/submitqueue/pull/${1000 + offset + index}/6666666666666666666666666666666666666666`] }, + strategy: Strategy.SQUASH_REBASE, + }))); + expectedIds.push(...replies.map(reply => reply.sqid)); + } + const newest = (await gateway.land({ + queue, + change: { uris: ["git://git.example.com/demo/refs%2Fheads%2Fpagination/7777777777777777777777777777777777777777"] }, + strategy: Strategy.SQUASH_REBASE, + })).sqid; + expectedIds.push(newest); + await expect.poll(async () => { + const ids = new Set(); + const toMs = BigInt(Date.now()); + let pageToken = ""; + do { + const response = await gateway.list({ + queue, receivedAtOrAfterMs: toMs - 86_400_000n, receivedBeforeMs: toMs, + pageSize: 50, pageToken, + }); + response.requests.forEach(request => ids.add(request.sqid)); + pageToken = response.nextPageToken; + } while (pageToken); + return expectedIds.every(id => ids.has(id)); + }, { timeout: 0 }).toBe(true); + + await page.goto(changePath); + await expect(page.getByRole("heading", { name: "refs/heads/pagination", exact: true })).toBeVisible(); + await expect(page.getByRole("table", { name: "Change submissions" }).getByRole("link", { name: newest, exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: oldest, exact: true })).toHaveCount(0); + await expect(page.getByText("More queue requests remain to be searched.", { exact: false })).toBeVisible(); + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + await page.getByRole("link", { name: "Continue history scan", exact: true }).click(); + await expect(page.getByRole("table", { name: "Change submissions" }).getByRole("link", { name: oldest, exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "Continue history scan", exact: true })).toHaveCount(0); + expect(new URL(page.url()).searchParams.has("page")).toBe(true); + await page.reload(); + await expect(page.getByRole("link", { name: oldest, exact: true })).toBeVisible(); + expect((await new AxeBuilder({ page }).analyze()).violations).toEqual([]); + await page.getByRole("button", { name: "Refresh", exact: true }).click(); + await expect(page.getByRole("link", { name: newest, exact: true })).toBeVisible(); + expect(new URL(page.url()).search).toBe(""); +}); diff --git a/web/test/package/BUILD.bazel b/web/test/package/BUILD.bazel new file mode 100644 index 000000000..cdb9f8d2e --- /dev/null +++ b/web/test/package/BUILD.bazel @@ -0,0 +1,58 @@ +load("@aspect_rules_js//npm:defs.bzl", "npm_link_package") +load("@aspect_rules_js//js:defs.bzl", "js_test") +load("@aspect_rules_ts//ts:defs.bzl", "ts_config", "ts_project") +load("@rules_python//python:defs.bzl", "py_test") + +package(default_visibility = ["//visibility:public"]) + +npm_link_package( + name = "node_modules/@submitqueue/web-submitqueue", + src = "//web/submitqueue:pkg", +) + +ts_config( + name = "tsconfig", + src = "tsconfig.json", +) + +ts_project( + name = "consumer_typecheck", + srcs = ["consumer.ts"], + declaration = False, + no_emit = True, + tsconfig = ":tsconfig", + deps = [ + ":node_modules/@submitqueue/web-submitqueue", + "//web:node_modules/@bufbuild/protobuf", + "//web:node_modules/@connectrpc/connect", + "//web:node_modules/@opentelemetry/api", + "//web:node_modules/pino", + "//web:node_modules/@types/node", + "//web:node_modules/react", + "//web:node_modules/react-dom", + ], +) + +py_test( + name = "pack_contents_test", + srcs = ["pack_contents_test.py"], + main = "pack_contents_test.py", + legacy_create_init = 0, + args = ["$(rootpath //web/submitqueue:pkg.pack)"], + data = ["//web/submitqueue:pkg.pack"], +) + +js_test( + name = "consumer_runtime_test", + entry_point = "consumer-runtime.mjs", + data = [ + ":node_modules/@submitqueue/web-submitqueue", + "//web/submitqueue:node_modules", + "//web:node_modules/@bufbuild/protobuf", + "//web:node_modules/@connectrpc/connect", + "//web:node_modules/@opentelemetry/api", + "//web:node_modules/pino", + "//web:node_modules/react", + "//web:node_modules/react-dom", + ], +) diff --git a/web/test/package/consumer-runtime.mjs b/web/test/package/consumer-runtime.mjs new file mode 100644 index 000000000..9be7c5139 --- /dev/null +++ b/web/test/package/consumer-runtime.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { SubmitQueueApp, SubmitQueueShell } from "@submitqueue/web-submitqueue"; +import { createSubmitQueueWeb, WebPaths } from "@submitqueue/web-submitqueue/server"; +import { newHmacCursorCodec } from "@submitqueue/web-submitqueue/extension/cursor/hmac"; +import { createFakeGatewayReader } from "@submitqueue/web-submitqueue/extension/gateway/mock"; + +const web = createSubmitQueueWeb({ + gateway: createFakeGatewayReader(), + cursors: newHmacCursorCodec("ordinary-server-signing-secret"), + paths: new WebPaths({ basePath: "/queues" }), +}); +const result = await web.handle({ path: "/queues/demo-queue", search: {} }); +assert.equal(result.kind, "render"); +assert.equal(result.model.result.ok, true); +assert.doesNotThrow(() => JSON.stringify(result.model)); +const html = renderToStaticMarkup(createElement(SubmitQueueShell, { homeHref: "/queues" }, + createElement(SubmitQueueApp, { model: result.model }))); +assert.ok(html.includes('href="/queues/demo-queue/request/demo-queue/1"')); +assert.ok(html.includes('aria-label="Queue requests"')); +const stylesheet = await readFile(new URL(import.meta.resolve("@submitqueue/web-submitqueue/styles.css")), "utf8"); +assert.ok(stylesheet.includes(".sq-app.site-frame")); +assert.ok(stylesheet.includes(".sq-app .sq-history")); +console.log("An ordinary Node/React host loaded and rendered the application with its packaged stylesheet."); diff --git a/web/test/package/consumer.ts b/web/test/package/consumer.ts new file mode 100644 index 000000000..3b6119f39 --- /dev/null +++ b/web/test/package/consumer.ts @@ -0,0 +1,16 @@ +// Typechecks a host written only against the package's public entry points. +import { SubmitQueueApp, SubmitQueueShell, WebNavigationProvider } from "@submitqueue/web-submitqueue"; +import { createSubmitQueueWeb, type SubmitQueueGateway } from "@submitqueue/web-submitqueue/server"; +import { createFakeGatewayReader } from "@submitqueue/web-submitqueue/extension/gateway/mock"; +import { newHmacCursorCodec } from "@submitqueue/web-submitqueue/extension/cursor/hmac"; + +const gateway: SubmitQueueGateway = createFakeGatewayReader(); +const web = createSubmitQueueWeb({ gateway, cursors: newHmacCursorCodec("consumer-owned-secret") }); + +export async function render() { + const result = await web.handle({ path: "/demo-queue", search: {} }); + if (result.kind !== "render") { + return null; + } + return [WebNavigationProvider, SubmitQueueShell, SubmitQueueApp, result.model] as const; +} diff --git a/web/test/package/pack_contents_test.py b/web/test/package/pack_contents_test.py new file mode 100644 index 000000000..ed585d0b6 --- /dev/null +++ b/web/test/package/pack_contents_test.py @@ -0,0 +1,93 @@ +import json +import posixpath +import re +import sys +import tarfile +from pathlib import Path + + +# The module reads the gateway through a structural contract; the transport, +# generated protos, and web framework belong to the host. +_FORBIDDEN_IMPORTS = ("@submitqueue/api", "@connectrpc/", "@bufbuild/", "next") + +# Modules the client-safe root entry must never reach, directly or transitively. +_CLIENT_FORBIDDEN_MODULES = ("package/dist/controller/", "package/dist/module.js", "package/dist/server.js") +_CLIENT_FORBIDDEN_IMPORTS = ("node:",) + +# Static `from`, side-effect `import "x"`, dynamic `import("x")`, and `require("x")` specifiers. +_IMPORT_PATTERN = re.compile(r"""(?:\bfrom|\bimport|\bimport\s*\(|\brequire\s*\()\s*["']([^"']+)["']""") + + +def _client_entry_violations(archive: tarfile.TarFile) -> list[str]: + sources = { + member.name: archive.extractfile(member).read().decode("utf-8") + for member in archive.getmembers() + if member.name.endswith(".js") + } + violations = [] + pending, seen = ["package/dist/index.js"], set() + while pending: + name = pending.pop() + if name in seen: + continue + seen.add(name) + if name.startswith(_CLIENT_FORBIDDEN_MODULES): + violations.append(name) + continue + for specifier in _IMPORT_PATTERN.findall(sources.get(name, "")): + if specifier.startswith("."): + pending.append(posixpath.normpath(posixpath.join(posixpath.dirname(name), specifier))) + elif specifier.startswith(_CLIENT_FORBIDDEN_IMPORTS): + violations.append(f"{name} -> {specifier}") + return violations + + +_EXPECTED_FILES = { + "package/view/styles.css", + "package/dist/index.d.ts", + "package/dist/server.d.ts", + "package/dist/extension/cursor/hmac/index.d.ts", + "package/dist/extension/gateway/mock/index.d.ts", + "package/README.md", +} + + +def _imports_forbidden(specifier: str) -> bool: + return any( + specifier.startswith(forbidden) if forbidden.endswith("/") else specifier == forbidden or specifier.startswith(forbidden + "/") + for forbidden in _FORBIDDEN_IMPORTS + ) + + +def main() -> int: + if len(sys.argv) != 2: + raise RuntimeError("expected the module package archive") + + with tarfile.open(Path(sys.argv[1])) as archive: + missing = sorted(_EXPECTED_FILES - {member.name for member in archive.getmembers()}) + if missing: + print(f"The package is missing: {', '.join(missing)}", file=sys.stderr) + return 1 + package = json.load(archive.extractfile("package/package.json")) + if "next" in package.get("peerDependencies", {}) or "next" in package.get("dependencies", {}): + print("The module must not require Next.js.", file=sys.stderr) + return 1 + stylesheet = package.get("exports", {}).get("./styles.css") + if stylesheet != "./view/styles.css" or stylesheet not in package.get("sideEffects", []): + print("The module must export and preserve its default stylesheet.", file=sys.stderr) + return 1 + if violations := _client_entry_violations(archive): + print(f"The client entry reaches server-only code: {', '.join(violations)}", file=sys.stderr) + return 1 + for member in archive.getmembers(): + if member.name.endswith((".js", ".d.ts")): + source = archive.extractfile(member).read().decode("utf-8") + for specifier in _IMPORT_PATTERN.findall(source): + if _imports_forbidden(specifier): + print(f"{member.name} imports {specifier}, which the host owns.", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/test/package/react18/BUILD.bazel b/web/test/package/react18/BUILD.bazel new file mode 100644 index 000000000..216621ca8 --- /dev/null +++ b/web/test/package/react18/BUILD.bazel @@ -0,0 +1,30 @@ +load("@aspect_rules_js//js:defs.bzl", "js_test") +load("@aspect_rules_js//npm:defs.bzl", "npm_link_package") +load("@npm//:defs.bzl", "npm_link_all_packages") + +npm_link_all_packages(name = "node_modules") + +[ + npm_link_package( + name = "node_modules/" + package, + src = target, + ) + for package, target in { + "@submitqueue/web-submitqueue": "//web/submitqueue:pkg", + }.items() +] + +# Resolves react from this package's React 18 links; the workspace root's +# React 19 must stay out of the runfiles or Node would find it first. +js_test( + name = "react18_runtime_test", + data = [ + ":node_modules", + ":node_modules/@submitqueue/web-submitqueue", + "//web:node_modules/@bufbuild/protobuf", + "//web:node_modules/@connectrpc/connect", + "//web:node_modules/@opentelemetry/api", + "//web:node_modules/pino", + ], + entry_point = "react18-runtime.mjs", +) diff --git a/web/test/package/react18/package.json b/web/test/package/react18/package.json new file mode 100644 index 000000000..ead9ec5a1 --- /dev/null +++ b/web/test/package/react18/package.json @@ -0,0 +1,11 @@ +{ + "name": "@submitqueue/web-react18-consumer", + "version": "0.0.0", + "private": true, + "type": "module", + "dependencies": { + "jsdom": "^26.1.0", + "react": "18.3.1", + "react-dom": "18.3.1" + } +} diff --git a/web/test/package/react18/react18-runtime.mjs b/web/test/package/react18/react18-runtime.mjs new file mode 100644 index 000000000..e7f39ff10 --- /dev/null +++ b/web/test/package/react18/react18-runtime.mjs @@ -0,0 +1,55 @@ +import assert from "node:assert/strict"; +import { JSDOM } from "jsdom"; +import React, { createElement } from "react"; +import { renderToString } from "react-dom/server"; +import { SubmitQueueApp, SubmitQueueShell } from "@submitqueue/web-submitqueue"; +import { createSubmitQueueWeb } from "@submitqueue/web-submitqueue/server"; +import { newHmacCursorCodec } from "@submitqueue/web-submitqueue/extension/cursor/hmac"; +import { createFakeGatewayReader } from "@submitqueue/web-submitqueue/extension/gateway/mock"; + +assert.match(React.version, /^18\./u); + +const web = createSubmitQueueWeb({ + gateway: createFakeGatewayReader(), + cursors: newHmacCursorCodec("react-18-consumer-secret"), +}); +const routes = [ + { path: "/", search: {} }, + { path: "/demo-queue", search: {} }, + { path: "/demo-queue/request/demo-queue/1", search: {} }, + { path: "/demo-queue/request/demo-queue/1", search: { view: "history" } }, +]; +const pages = []; +for (const route of routes) { + const result = await web.handle(route); + assert.equal(result.kind, "render", `${JSON.stringify(route)} did not render`); + const page = createElement(SubmitQueueShell, null, createElement(SubmitQueueApp, { model: result.model })); + pages.push({ route, page, html: renderToString(page) }); +} + +const dom = new JSDOM("", { url: "https://submitqueue.test/" }); +for (const name of ["window", "document", "navigator"]) { + Object.defineProperty(globalThis, name, { configurable: true, value: name === "window" ? dom.window : dom.window[name] }); +} +const errors = []; +const reportError = console.error; +console.error = (...values) => errors.push(values.map(String).join(" ")); +const { hydrateRoot } = await import("react-dom/client"); +// act flushes hydration before unmounting; unmounting a root that is still +// waiting to hydrate is itself reported as a hydration failure. +const { act } = React; +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +for (const { route, page, html } of pages) { + const container = document.createElement("div"); + container.innerHTML = html; + document.body.append(container); + let root; + await act(async () => { + root = hydrateRoot(container, page); + }); + await act(async () => root.unmount()); + container.remove(); + assert.deepEqual(errors, [], `${JSON.stringify(route)} failed to hydrate on React ${React.version}`); +} +console.error = reportError; +console.log(`React ${React.version} rendered and hydrated ${pages.length} packaged pages.`); diff --git a/web/test/package/tsconfig.json b/web/test/package/tsconfig.json new file mode 100644 index 000000000..781744e9c --- /dev/null +++ b/web/test/package/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2023" + }, + "include": ["consumer.ts"] +} diff --git a/web/tool/BUILD.bazel b/web/tool/BUILD.bazel new file mode 100644 index 000000000..a01ee0863 --- /dev/null +++ b/web/tool/BUILD.bazel @@ -0,0 +1,3 @@ +exports_files([ + "generate_api.bzl", +]) diff --git a/web/tool/README.md b/web/tool/README.md new file mode 100644 index 000000000..2b6244c2f --- /dev/null +++ b/web/tool/README.md @@ -0,0 +1,3 @@ +# Web build rules + +- `generate_api.bzl`: hermetic Protobuf-ES generation for [`api/`](../api/). diff --git a/web/tool/generate_api.bzl b/web/tool/generate_api.bzl new file mode 100644 index 000000000..ba2de0753 --- /dev/null +++ b/web/tool/generate_api.bzl @@ -0,0 +1,95 @@ +"""Hermetic TypeScript protobuf generation for the web workspace.""" + +load("@aspect_rules_js//js:libs.bzl", "js_binary_lib") + +def _generate_web_api_impl(ctx): + output = ctx.actions.declare_directory(ctx.label.name) + template = ctx.actions.declare_file(ctx.label.name + ".buf.gen.yaml") + module = ctx.actions.declare_directory(ctx.label.name + "_module") + stage_arguments = ctx.actions.args() + stage_arguments.add(module.path) + for source in ctx.files.srcs: + stage_arguments.add(source) + stage_arguments.add(source.short_path) + ctx.actions.run_shell( + arguments = [stage_arguments], + command = """ +set -euo pipefail +output="$1" +shift +while (( "$#" )); do + source="$1" + relative="$2" + mkdir -p "$output/$(dirname "$relative")" + cp "$source" "$output/$relative" + shift 2 +done +""", + inputs = ctx.files.srcs, + outputs = [module], + mnemonic = "WebProtoStage", + ) + + bin_to_execroot = "../" * len(ctx.bin_dir.path.split("/")) + protoc_gen_es = bin_to_execroot + ctx.executable.protoc_gen_es.path + ctx.actions.write( + output = template, + content = "\n".join([ + "version: v2", + "clean: true", + "plugins:", + " - local: %s" % protoc_gen_es, + " out: %s" % output.short_path, + " opt:", + " - target=ts", + " - import_extension=js", + "", + ]), + ) + + arguments = [ + "generate", + module.short_path, + "--template=%s" % template.short_path, + ] + for source in ctx.files.srcs: + arguments.extend([ + "--path", + module.short_path + "/" + source.short_path, + ]) + + js_binary_lib.run_binary_action( + ctx = ctx, + executable = ctx.executable.buf, + arguments = arguments, + inputs = depset([module, template]), + outputs = [output], + env = ctx.configuration.default_shell_env, + tools = [ + ctx.attr.buf[DefaultInfo].files_to_run, + ctx.attr.protoc_gen_es[DefaultInfo].files_to_run, + ], + mnemonic = "WebProtoGenerate", + progress_message = "Generating TypeScript protobuf API %{label}", + ) + return [DefaultInfo(files = depset([output]))] + +generate_web_api = rule( + implementation = _generate_web_api_impl, + attrs = { + "buf": attr.label( + executable = True, + cfg = "exec", + mandatory = True, + ), + "protoc_gen_es": attr.label( + executable = True, + cfg = "exec", + mandatory = True, + ), + "srcs": attr.label_list( + allow_files = [".proto"], + mandatory = True, + ), + }, +) diff --git a/web/tsconfig.base.json b/web/tsconfig.base.json new file mode 100644 index 000000000..1fa82e87f --- /dev/null +++ b/web/tsconfig.base.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "allowJs": false, + "declaration": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2023" + } +} diff --git a/web/tsconfig.e2e.json b/web/tsconfig.e2e.json new file mode 100644 index 000000000..f27ebabbe --- /dev/null +++ b/web/tsconfig.e2e.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["test/e2e/**/*.ts", "playwright.config.ts"] +} diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 000000000..9b6cee87b --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + clearMocks: true, + coverage: { + provider: "v8", + }, + }, +});