diff --git a/CHANGELOG.md b/CHANGELOG.md index 958d3b282..505b5d450 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to - ✨(loadtest) add a websocket load generator for the collaboration server - ✨(loadtest) add k6 scenarios for the page-open sequence and the heavy endpoints +- ✨(loadtest) add browser canaries measuring what a user feels under load - ✨(backend) measure the calls to yhub and to the converters, the database pool and the celery queue - ✨(backend) add a `LoadTest` configuration and its `loadtest` application, diff --git a/documentation/stress-test-plan.md b/documentation/stress-test-plan.md new file mode 100644 index 000000000..bcb880f62 --- /dev/null +++ b/documentation/stress-test-plan.md @@ -0,0 +1,463 @@ +# Stress test plan — yhub architecture (next major release) + +Status: plan, written 2026-09-18 from a code read of branch `yhub`. Nothing in +here has been run yet. File and line references are valid for that branch at +that date; re-check them before relying on them. + +## Goal + +Determine whether the new architecture (Django backend + `yhub-server` built on +`@y/hub`, Valkey streams, Postgres persistence) scales, and find where it stops +scaling, before the release reaches production. + +Environment: preprod, loaded with an anonymized copy of the production database +on which the full migration has been run, so that the volumetry is realistic. + +Deliverable: a capacity table (sockets per yhub pod, connects per second per +backend pod, edits per second per yhub worker, migrated documents per hour) +that tells how to size production and where autoscaling makes sense. + +## Architecture facts that shape the tests + +Collaboration path: + +- The browser uses plain `y-websocket` `WebsocketProvider` + (`src/frontend/apps/impress/src/features/docs/doc-management/stores/useProviderStore.tsx:154-160`) + with `disableBc: true`, `maxBackoffTime: 30000`, `resyncInterval: 20000`. + Wire protocol is y-protocols sync + awareness. +- WS URL: `wss://{host}/collaboration/ws/v1/docs/{docid}`. The doc id must be a + lowercase uuid4, the org must be `docs`. +- HTTP fallback on the same rooms (`@y/yhub-http-fallback`), about one poll + every 10 s, only while the socket is down. +- Authentication of the WS upgrade is the Django session cookie, forwarded by + yhub to the backend. There is no `collaboration-auth` ingress subrequest + anymore. +- Every WS connect, reconnect and fallback request triggers 2 to 3 Django + calls from yhub: `GET /users/me/` (`src/yhub-server/src/server.ts:190`), + `GET /documents/{id}/` (`:252`), and `GET /documents/{id}/accesses/me/` when + the user can list versions (`:279`). +- `backendFetch` in yhub has no timeout and no retry + (`src/yhub-server/src/backend.ts:116-139`). A failure gives a 503, which + `y-websocket` retries forever. +- yhub pods are stateless, cross-pod fan-out goes through Valkey, there are no + sticky sessions (`upstream-hash-by` was removed on purpose). +- A worker drains the Valkey stream into Postgres. Throughput is + `YHUB_TASK_CONCURRENCY` (default 5) times worker replicas, debounce + `YHUB_TASK_DEBOUNCE_MS` 10000. +- Each compaction fires `POST /documents/{id}/content-updated/` on Django + (5 s timeout, not awaited). Django updates the row and triggers the search + indexer, which calls `get_ydoc` back on yhub. This is a feedback loop. + +Backend: + +- The Django to yhub client opens a new connection per call, with no + `requests.Session`, no retry, 30 s timeout + (`src/backend/core/services/yhub_services.py:196-229`). +- Synchronous yhub calls inside HTTP requests: document creation from a file, + `duplicate` (2 round-trips per node, inside `transaction.atomic`), + `formatted-content` (yhub + converter), `create-for-owner`, first login with + the onboarding sandbox document. +- Asynchronous (Celery): delete, restore and access changes walk the whole + subtree with one HTTP call per node. Single default queue, 1 worker replica. +- No `CONN_MAX_AGE`. The psycopg pool exists but is off by default + (`DB_PSYCOPG_POOL_ENABLED`). +- uvicorn, `WEB_CONCURRENCY=4`, `--limit-max-requests=20000`. +- Sessions are cache-backed (`SESSION_ENGINE = cache`). +- DRF throttles: 80/min per user on documents, 50/min on document accesses. + yhub is exempt through `X-Y-Provider-Key`. + +Migration: + +- One cheap Django migration (`0034_documentmigration`). +- `migrate_documents`: thread pool, `--concurrency` default 2, `--rate`, + `--limit`, resumable and idempotent. +- `SOFT_MIGRATION=true`: unmigrated documents are seeded on first open, inside + the WS upgrade handler. Each replica runs at most `MAX_CONCURRENT_SEEDS=20` + seeds at once and fails fast beyond that: the caller gets a 503 and relies + on the client's retry backoff, it is not queued. A 30 s Redis lock + serialises seeds of the same document across replicas + (`src/yhub-server/src/migration.ts:63-64`, `:419-429`). +- `generate_volumetry` writes no document content. Only `create_demo` and + `migrate_documents` put content in yhub. + +Deployment (`src/helm`): + +- No HPA anywhere, `resources: {}` on every impress container, static replicas + (backend 3, yhub 3, yhub worker disabled by default, Celery 1). +- When this plan was written there was no metrics endpoint in Django or yhub + (done since, see sections 0.1 and 0.2). +- Valkey is not deployed from this repository on real clusters: another team + runs it, through the valkey operator (Sentinel mode), one instance for the + backend (`valkey-docs`: cache, sessions, Celery) and one for yhub + (`valkey-yhub`). In this repository, dev and feature only run two standalone + instances from the `valkey/valkey` chart + (`src/helm/env.d/{env}/values.valkey.yaml.gotmpl`), with + `maxmemory-policy volatile-lru`. Stream entries without a TTL cannot be + evicted under that policy, so the memory limit of `valkey-yhub` matters. +- The only nginx auth subrequest left is `media-auth`. + +Frontend: + +- On connection loss, each client waits up to 3 s of jitter, then refetches + `GET /documents/{id}/`. +- The service worker `SyncManager` replays queued mutations with no backoff + and no jitter + (`src/frontend/apps/impress/src/features/service-worker/SyncManager.ts:17-45`). + +## 0. Prerequisites + +Without metrics a test only shows that something broke, not where. Real +clusters are synced by Argo CD and there is no kubectl access, so dashboards +are the only way to observe a run. Everything below ships through git and +helm values. + +First question to settle with the ops team: what scrapes metrics in preprod +(Prometheus operator with ServiceMonitor and PodMonitor, or annotation-based +scraping) and where dashboards live. The chart work depends on the answer. + +### 0.1 yhub-server + +**Implemented** (2026-09-18): metrics and Sentry. See the "Metrics" and "Error +reporting" sections of `src/yhub-server/README.md`. + +- `PROMETHEUS_METRICS_ENABLED=true` and `PROMETHEUS_API_KEY` (required) start + a listener on its own port (`9464`) in every role, the worker included. It + answers one path to a bearer token, like the backend's `/metrics`. +- Chart: `yhub.metrics.enabled`. `serviceMonitor.enabled` or + `podMonitor.enabled` then scrapes every pod from inside the cluster (one + monitor per component, `backend.metrics.enabled` for the backend); the + dedicated `ingressMetrics` publishes `/metrics/yhub` and + `/metrics/yhub-worker` next to the backend's `/metrics` for a Prometheus + outside of it. +- `@y/hub` 0.9.0 owns the uWebSockets server and has no hook on socket open, + close or message. What is measured is what our code and its public events + allow: + + | Metric | What to watch during a run | + |---|---| + | `yhub_ws_connections`, `yhub_rooms` | sockets and open documents per replica (read off `stream.subs`, an internal of yhub) | + | `yhub_auth_duration_seconds{phase,endpoint,result}` | cost of admitting a caller: upgrades, rechecks, fallback polls. `result="unavailable"` is a 503 sent to the client | + | `yhub_backend_request_duration_seconds{route,status}`, `yhub_backend_requests_inflight{route}` | the 2 to 3 Django calls per connect. Inflight is what piles up when Django slows down, since `backendFetch` has no timeout | + | `yhub_worker_pending_tasks` | compaction backlog for the whole deployment: use `max`, not `sum` | + | `yhub_worker_task_duration_seconds`, `yhub_worker_tasks_inflight` | worker saturation against `YHUB_TASK_CONCURRENCY` | + | `yhub_doc_updates_total` | rate of `content-updated` notifications sent to Django | + | `yhub_seed_duration_seconds`, `yhub_seeds_inflight`, `yhub_seed_rejected_total` | soft migration under load, and the opens refused at 20 concurrent seeds | + | `nodejs_eventloop_lag_seconds` | the first signal of a saturated replica: one thread serves all its sockets | + +- Not visible from inside yhub: messages and bytes per socket, close codes, + Postgres pool state. Read them from the Valkey exporter, ingress-nginx and + `pg_stat_activity` on the `yhub` database. +- Logs: `@y/hub` logs every socket connect and close at info level. Check the + log pipeline can take it at 10k sockets. +- Sentry: `error` and `fatal` log lines are reported. The refusal of a seed at + 20 concurrent ones is logged at error level (`seed.failed`), so the soft + migration scenario will send one Sentry event per refused open. Lower that + log line to `warn`, or expect the volume. + +Decision still to take before the first storm scenario: `backendFetch` has no +timeout. Either leave it as is for the first run, to measure the real +behaviour, or add `AbortSignal.timeout` first. + +### 0.2 Django backend + +1. Request and SQL metrics: **implemented** (2026-09-18), see + `documentation/metrics.md`. `django-prometheus` is opt-in + (`PROMETHEUS_METRICS_ENABLED`), labels are view name, method and status. + - Served on `/metrics` (not under `/api/`), behind a bearer token + (`PROMETHEUS_API_KEY`, required) checked by the first middleware. The + chart has a dedicated `ingressMetrics`, to be filtered by address. + - The uvicorn workers share their numbers through a local directory + (multiprocess mode, required with `WEB_CONCURRENCY=4`), defaulted so that + nothing has to be configured. + - Scraping through the ingress reaches a different pod each time. Every + sample has a `hostname` label so the replicas stay apart, but each series + is only sampled one scrape out of N. For the load test, with many backend + pods, prefer scraping each pod from inside the cluster if preprod allows + it; otherwise use a short scrape interval and 5 min rate windows. + - Known limit: workers recycled by `--limit-max-requests=20000` leave + their files behind, so the directory grows until the pod is replaced. + Watch `scrape_duration_seconds` during the soak scenario. +2. Custom metrics on the known pressure points: **implemented** + (2026-09-21), see `documentation/metrics.md`. + - `docs_outgoing_request_duration_seconds{service,operation,method,status}` + and `docs_outgoing_requests_inflight`: the calls to yhub + (`YHubService.request`) and to the two converters, timeouts told apart + from errors; + - `docs_db_pool_*`: state and exact counters of the psycopg pool when + `DB_PSYCOPG_POOL_ENABLED` is on. `requests_queued_total` and + `requests_wait_seconds_total` are the signal that was missing in the + 2026-08-18 and 2026-09-07 outages; + - `docs_celery_queue_length`: backlog of the single default queue the + subtree cascades land on (use `max`, not `sum`); + - not done: Celery task duration by task name. The workers serve no + endpoint, so it needs either a pushgateway or the multiprocess directory + shared with a small exporter. +3. Query-level detail: silk is already wired. Enable it in preprod with + `SILKY_INTERCEPT_PERCENT` at 1 to 2 % and run `purge_silk_profiles` + between runs. Set `SENTRY_TRACES_SAMPLE_RATE` (default 0.0, + `settings.py:532`) to about 0.01 if Sentry is available in preprod, for + traces across Django, Celery and outgoing HTTP calls. +4. Request logs: keep `LOGGING_LEVEL_REQUEST_SUMMARY` on, the dockerflow + summary carries the path, status and duration of every request and is the + fallback when a metric is missing. +5. Configuration parity with production: `DB_PSYCOPG_POOL_*`, pooler, + `WEB_CONCURRENCY`, cache and session Valkey settings, Celery concurrency, + throttle rates. Disable outgoing emails, PostHog, AI and webhooks. + +### 0.3 Synthetic sessions + +**Implemented** (2026-09-21) as a dedicated Django application, +`src/backend/loadtest/`, that only the `LoadTest` configuration installs. + +Why it is needed: authentication is session-only +(`DEFAULT_AUTHENTICATION_CLASSES` holds only `SessionAuthentication`) and the +Keycloak realm has 4 static users, so load clients cannot log in through OIDC at +scale. Sessions are cache-backed, `MIDDLEWARE` contains no OIDC token-refresh +middleware, and yhub forwards the same cookie to `/users/me/`, so a session +written straight to the store covers HTTP, WS and the HTTP fallback. + +How it is kept out of production: + +- `LOAD_TEST_TOOLS_ENABLED` is `False` in `Base`, pinned to `False` again in + `Production` (which `Feature`, `Staging`, `PreProduction` and `Demo` inherit), + and is not read from the environment: no variable can turn it on. +- `class LoadTest(Production)` is the only configuration that sets it to `True` + and adds `loadtest` to `INSTALLED_APPS`. Everywhere else the commands do not + exist (`Unknown command`). +- The application refuses to load when the setting is off, so adding it to + another configuration stops the process from starting. +- The commands check the setting again, and refuse the `Production` + configuration by name. + +Usage, in preprod: deploy the backend with `DJANGO_CONFIGURATION=LoadTest`, +then run the commands through the chart's generic backend job (Argo CD +`PostSync` hook, `backend.job.command`): + +```bash +python manage.py create_load_test_sessions 5000 --heaviest 50 \ + --storage-name campaign-1.json --ttl-hours 8 +python manage.py revoke_load_test_sessions --storage-name campaign-1.json +``` + +- Users: active, not staff, not superuser, with at least one access to a live + document. `--heaviest N` takes the N users holding the most accesses, the + rest is a random draw. Staff are excluded because a minted session of theirs + would open the admin. +- Manifest (JSON): `cookie_name`, `expires_at`, `public_documents`, and per + session `user_id`, `session_key`, `editable_documents`, + `readonly_documents` (most recently updated first, `--documents-per-user` + each). This is what the swarm and k6 read to open documents they are allowed + to open. +- The manifest holds live cookies and is a secret. It goes to a `0600` file + (`--output`) or to a private object under `loadtest/` in the default storage + (`--storage-name`), never to stdout or logs. The media route only serves + `{document id}/attachments/…` keys, so that prefix cannot be fetched through + `/media/`. The load generator needs read access to that object. +- Revocation reads an index kept next to the sessions, so it works without the + manifest and across several runs. Sessions live 12 h by default, 7 days at + most. + +Rules for the load clients: + +- One distinct user per virtual client, otherwise the per-user DRF throttles + (80/min on documents) distort results. Keep the throttles on, they are part + of production behaviour. +- Keep the cookie jar. `ForceSessionMiddleware` creates a 12 h session in + Valkey for every request that carries none. +- Team-based accesses are not listed in the manifest, only direct ones. +- `documents/search/` refreshes the OIDC token when + `OIDC_STORE_REFRESH_TOKEN` is on: minted sessions hold no token, so leave + that endpoint out of the scenarios or keep the setting off in preprod. + +### 0.4 Chart and infrastructure + +- Done in the chart: the metrics of the backend, of yhub and of its worker + are served behind a bearer token, and `ingressMetrics` publishes them on a + dedicated host (`/metrics`, `/metrics/yhub`, `/metrics/yhub-worker`), to be + filtered by address. Through an ingress each scrape reaches one replica at + random: if the preprod Prometheus runs inside the cluster, scraping each pod + directly (ServiceMonitor and PodMonitor carrying the token) gives better data + with many replicas, and would be a chart addition. +- Valkey, to ask the team that runs it (it is not managed from this + repository): the exporter enabled on `valkey-docs` and `valkey-yhub`, their + metrics in the same Prometheus, and the memory limit and `maxmemory-policy` of + each instance in preprod and in production. +- Postgres: pghero or `pg_stat_statements`, connection counts per database + and per application name, replication and failover state. +- ingress-nginx metrics: request rate, latency, active connections, per + ingress. +- Pod CPU, memory, restarts and throttling for every component, load + generators included. +- Preprod matches production on replicas, resource requests and limits, + Postgres pooler, Valkey memory and eviction policy for `valkey-docs` and + `valkey-yhub`, yhub worker replicas and `YHUB_TASK_CONCURRENCY`. If preprod + differs from production, results are only relative. + +### 0.5 Dashboards + +One board per question, built before the first run: + +- users: `canary_page_open_seconds`, `canary_editor_ready_seconds`, + `canary_propagation_seconds`, `canary_failures_total{step}` (from + `src/loadtest/canary/`); +- yhub: sockets and rooms per pod, event-loop lag, auth duration, backend + call duration and inflight, pending tasks, task duration, seeds; +- Django: latency and rate per view, yhub client latency, pool waiting, + Celery queue length; +- stores: Postgres connections and top queries, Valkey memory, commands and + evictions. + +## 1. Tooling + +- HTTP: k6 (scenarios, thresholds, Prometheus output). Replay the real + page-open sequence: `config`, `users/me`, `documents/{id}`, tree, list, + plus `media-auth`. +- Collaboration: a Node swarm using the real client stack (`yjs`, + `y-websocket`, `ws` with the cookie header, same options as the frontend). + One process holds a few thousand sockets. Run it as a Job that goes through + the ingress, on nodes that do not host the application. + - Latency: writers stamp a timestamp into the doc, readers measure the + propagation delay. + - Convergence: compare state vectors across clients at the end. + - Durability: compare against the admin `ydoc` endpoint. + - k6 cannot reasonably speak the Yjs protocol, which is why this is a + separate tool. +- Playwright canaries: 3 to 5 browsers reusing the `auth.setup.ts` storage + state and the helpers in `src/frontend/apps/e2e` (`createDoc`, + `writeInEditor`). They measure the user-perceived time to open a document + and start typing while the swarm applies load. +- Workload model from production, not guesses: + - `profile_volumetry` for the data shape; + - access logs or PostHog for peak concurrent users, connections per + document (long tail: mostly 1, a few at 50+), edit rate, reader/writer + ratio. + - Run at 1x, 2x and 5x, then ramp until the knee. + +Location of the tooling: `src/loadtest/` (`swarm/` exists, see its README). + +Note from the dev-stack check of the swarm: `create_load_test_sessions` writes +the sessions where the `LoadTest` configuration keeps them. In preprod the +server runs that same configuration; a server running another one reads its +sessions elsewhere (the dev stack's `Development` uses Redis db 2, `LoadTest` +db 0) and the cookies are worth nothing to it. yhub then admits the clients +anonymously on public documents, which looks like success: check the `userid` +in yhub's logs once before a campaign. + +## 2. Scenarios, in order of value + +1. **Migration.** Time the `migrate_documents` backfill at full volumetry: + documents per second, growth of the yhub Postgres database, total + duration, best `--concurrency`. Then test the `SOFT_MIGRATION` path under + load: many users opening unmigrated documents at once. Each replica + refuses seeds beyond 20 at once with a 503, so measure how many opens are + refused and how long clients take to get in. +2. **HTTP baseline against the current release.** Same dataset, same k6 + script. Catches regressions unrelated to yhub. +3. **Connect ramp.** Find the connects-per-second ceiling per backend pod. + `documents/{id}/` is not cheap: ancestor annotation, LinkTrace `exists()` + and a possible INSERT. +4. **Idle steady state.** Ramp to 10k or more open sockets. Measure memory + per connection, the 20 s resync floor, Valkey pub/sub traffic. +5. **Hot document.** 50 to 200 editors plus readers on one document. + Awareness fan-out grows with the square of the number of clients. +6. **Wide editing.** Thousands of documents with 1 to 3 typists each. + Stresses stream to worker to Postgres, and the `content-updated` to + indexer to `get_ydoc` feedback loop. Watch stream length and worker lag. +7. **Reconnect storms.** Restart yhub (Argo-driven rollout), call + `reset-connections`, trigger a Valkey failover and a Patroni failover (the + Valkey one has to be run by the team that operates it). + Clients return within 30 s of backoff plus 3 s of jitter, each costing 2 + to 3 Django calls and a document refetch. With no timeout in + `backendFetch`, a slow Django means piled-up upgrades. This is the failure + shape of the 2026-08-18, 2026-09-07 and 2026-09-14 outages. +8. **WS blocked, forcing the HTTP fallback.** Every poll re-runs the Django + permission calls: N clients generate roughly 0.3 N requests per second on + the backend. Probably the worst amplification in the system. +9. **Heavy synchronous endpoints.** Duplicate with descendants (holds a + Postgres connection in a transaction during N yhub round-trips), + `formatted-content`, import, and delete, restore and access changes on deep + trees (one HTTP call per node on a single Celery queue with 1 worker). +10. **Large and long-history documents.** Open the 100 largest documents + after migration and measure time to first sync. +11. **Soak.** 4 to 8 h at 1x. Watch memory leaks, Valkey memory growth, + Postgres bloat in the yhub database, uvicorn worker recycling. +12. **Offline replay burst (browser only).** A fleet coming back online + replays queued mutations at once. Lower priority. + +## 3. Method + +- Define SLOs first. Example targets: document open p95 under 2 s, edit + propagation p95 under 500 ms, errors under 0.1 %, zero divergence, zero + lost updates. +- One scenario at a time, stepped ramps with plateaus. +- At each knee, record which resource saturated (CPU, Postgres connections, + Valkey memory, Node event loop), fix it, rerun. +- Keep each run's parameters, dashboards snapshot and results together so + runs are comparable. + +## 4. Hypotheses to verify + +Suspected weak points from the code read, none confirmed by measurement: + +- 2 to 3 uncached Django calls per WS connect and per fallback poll. +- No timeout or retry in yhub `backendFetch`. +- Django to yhub client without connection reuse, 30 s timeout, called inside + `transaction.atomic` in `duplicate`. +- `content-updated` to indexer to `get_ydoc` feedback loop under wide editing. +- Single Celery queue with 1 replica for subtree cascades. +- Limit of 20 concurrent seeds per replica during soft migration, refused + with a 503 beyond it, inside the upgrade handler. +- `migrate_documents` default concurrency of 2 making the backfill very long. +- Valkey memory: non-evictable stream entries against a small limit. +- No database connection persistence or pooling by default. +- No HPA and no resource requests, so scheduling and scaling are untested. +- `SyncManager` replay without backoff or jitter. +- JWKS fetch inside request authentication on cache miss (10 s timeout). + +## 4b. Findings so far + +Found while checking the tooling against the dev stack, before any campaign: + +- **Concurrent `duplicate` calls collide on treebeard paths** (2026-09-22). + Three users duplicating their own document at once, in `heavy.js`, get a + 500 (`IntegrityError: duplicate key value violates unique constraint + "impress_document_path_key"`) or a 400 (`Document with this Path already + exists`) on about a request out of seven. Root path allocation races between + requests. To fix before the campaign, or scenario 9 measures the bug rather + than the endpoint. + +## 5. Work to do in this repository + +1. Done: metrics in the backend (`/metrics`) and in `src/yhub-server`, Sentry + in yhub, and the dedicated `ingressMetrics` in the chart (sections 0.1 and + 0.2). +2. Done: the `loadtest` application and the `LoadTest` configuration, minting + sessions for synthetic clients (section 0.3). +3. Load generators reading the manifest of section 0.3, under `src/loadtest/` + (not `src/backend/loadtest/`, which is the Django application): + - Done (2026-09-22): the Node Yjs swarm, `src/loadtest/swarm/` (see its + README). Modes `idle`, `hot`, `wide`, a ramp, a reconnect storm, + propagation latency, convergence check, `/metrics`, a JSON report, a + Dockerfile. Checked against the dev stack with sessions minted by + `create_load_test_sessions`. + - Done (2026-09-22): the k6 scripts, `src/loadtest/k6/` (see its README): + `page-open.js`, the HTTP baseline (scenario 2) as a rate-based ramp of the + frontend's document-open sequence plus `media-auth`; `heavy.js`, the + synchronous endpoints (scenario 9): duplicate with descendants, + `formatted-content`, delete/restore cascades, creation from a file. Both + checked against the dev stack. + - Done (2026-09-22): the canaries, `src/loadtest/canary/` (see its README). + Real Chromium browsers in writer/reader pairs, through the real frontend + and editor: page open, editor ready (provider synced), keystroke to the + other screen, failures by step, on `/metrics` and in a JSON report. + Checked against the dev stack: open p50 0.5 s, editor ready 1.3 s, + propagation 70 ms with nothing else running. + - Not done: the durability check against the admin `ydoc` endpoint (it + needs an admin JWT the swarm should not hold), and the HTTP fallback + scenario (`@y/yhub-http-fallback` is not driven by the swarm). +4. To do: preprod helm values — `DJANGO_CONFIGURATION=LoadTest`, metrics + enabled on both services, resources and replicas matching production. + Outside of this repository: the Valkey exporter and sizing, with the team + that runs Valkey. +5. Done, from section 0.2: the custom backend metrics (outgoing calls, + psycopg pool, Celery queue length). Left: Celery task duration. diff --git a/src/loadtest/canary/Dockerfile b/src/loadtest/canary/Dockerfile new file mode 100644 index 000000000..0fe7a3f31 --- /dev/null +++ b/src/loadtest/canary/Dockerfile @@ -0,0 +1,19 @@ +# Built from the repository root, like every other image here: +# docker build -f src/loadtest/canary/Dockerfile -t docs-canary . +# The base image carries the browsers Playwright needs, at the same version as +# the `playwright` package in package.json: bump both together. +FROM mcr.microsoft.com/playwright:v1.62.1-noble AS base +WORKDIR /app +COPY ./src/loadtest/canary/package.json ./src/loadtest/canary/yarn.lock ./ + +FROM base AS builder +RUN yarn install --frozen-lockfile +COPY ./src/loadtest/canary/tsconfig.json ./src/loadtest/canary/tsconfig.build.json ./ +COPY ./src/loadtest/canary/src ./src +RUN yarn build + +FROM base AS canary +RUN yarn install --frozen-lockfile --production +COPY --from=builder /app/dist ./dist +USER pwuser +ENTRYPOINT ["node", "dist/index.js"] diff --git a/src/loadtest/canary/README.md b/src/loadtest/canary/README.md new file mode 100644 index 000000000..f8088e0e2 --- /dev/null +++ b/src/loadtest/canary/README.md @@ -0,0 +1,96 @@ +# Canary — a few real browsers, measuring what a user would feel + +While the swarm and k6 apply load, the canary keeps a handful of real Chromium +browsers opening and editing documents through the real frontend, the real +editor and the real collaboration path, and times what a user would notice: + +| Metric | What it is | +| ------ | ---------- | +| `canary_page_open_seconds` | From the navigation to the document page being visible | +| `canary_editor_ready_seconds` | From the navigation to the editor accepting input, i.e. the collaboration provider synced | +| `canary_propagation_seconds` | From a keystroke in one browser to the text showing in another browser on the same document | +| `canary_iterations_total{result}`, `canary_failures_total{step}` | Iterations, and the step the failed ones died at (`page-open`, `editor-ready`, `focus`, `propagation`) | +| `canary_console_errors_total` | Errors the pages logged | + +It is the "users" board of the plan (`documentation/stress-test-plan.md`, +section 0.5): the swarm and k6 tell what the servers do, the canary tells what +it feels like. It is not a load generator — a few pairs of browsers, nothing +more — and it is the collaboration half's counterpart of the e2e tests, built +with the Playwright library rather than its test runner so that it can run for +hours and export metrics. + +Each pair is a writer and a reader on one document. The reader keeps the +document open for the whole run, like a colleague who has the page open. Every +`--interval` seconds the writer opens the document in a new tab, waits for the +editor, types a token at the end of the document, and the reader waits for it +to show; the token is then erased. The document is left as it was found, but +the edits are in its history: **only run this against anonymised data**. + +## Who the browsers are + +By default the manifest that the backend's `create_load_test_sessions` command +writes (`src/backend/loadtest`, only with the `LoadTest` configuration): each +pair takes a session and opens one of that user's editable documents (or +`--doc`). The session cookie and a CSRF cookie are set on the browser context, +which is what a login would have left. **The manifest holds live sessions and +is a secret.** + +`--storage-state file.json --doc ` runs with a Playwright storage state +instead — a real OIDC session, saved the way `src/frontend/apps/e2e`'s +`auth.setup.ts` does. + +A user who has never opened Docs is shown a tour on the first document; the +canary skips it, as that user would. + +## Running + +```bash +cd src/loadtest/canary +yarn install --frozen-lockfile && npx playwright install --with-deps chromium && yarn build +node dist/index.js --manifest manifest.json --url https://docs.example.com \ + --pairs 3 --duration 3600 --interval 5 +``` + +| Option | Default | What it does | +| ------ | ------- | ------------ | +| `--manifest` | — | The manifest file. Required, unless `--storage-state` | +| `--url` | — | `https://host` of the frontend. Required | +| `--pairs` | `2` | Pairs of browsers (each a writer and a reader) | +| `--duration` | `300` | Seconds to run | +| `--interval` | `5` | Seconds a pair waits between two iterations | +| `--doc` | one of the user's | The document every pair opens | +| `--timeout` | `30` | Seconds before an open, a focus or a propagation is given up on | +| `--headed` | off | Show the browsers | +| `--storage-state` | — | Playwright storage state, in place of the manifest's cookies | +| `--screenshots` | — | Directory where a screenshot of each failed iteration goes | +| `--metrics-port` | `9466` | Where `/metrics` is served, `0` for nowhere; `--metrics-token` requires a bearer token | +| `--report` | `-` | Where the JSON report goes, `-` for stdout | + +Progress goes to stderr, one line per iteration. The exit code is `0` when at +least one iteration ran and none failed. + +Keep it on the dev stack's `http://localhost:3000` for a local check: the +frontend served by the compose `frontend-development` container, or `yarn dev` +in `src/frontend/apps/impress`. + +## Container + +```bash +docker build -f src/loadtest/canary/Dockerfile -t docs-canary . +docker run --rm -v ./manifest.json:/manifest.json:ro docs-canary \ + --manifest /manifest.json --url https://docs.example.com --pairs 3 +``` + +Run it where the swarm runs: in the cluster, on nodes that do not host the +application, through the ingress. A browser costs a few hundred MB: size the +pod for `2 × --pairs` of them. + +## Development + +```bash +yarn typecheck && yarn lint && yarn test +``` + +The tests drive a real Chromium against a page served in-process that behaves +like the editor: a skeleton, then a `contenteditable`, behind a tour to skip, +mirroring what is typed to the other tab through the server. diff --git a/src/loadtest/canary/__tests__/canary.spec.ts b/src/loadtest/canary/__tests__/canary.spec.ts new file mode 100644 index 000000000..bfec1d5fa --- /dev/null +++ b/src/loadtest/canary/__tests__/canary.spec.ts @@ -0,0 +1,216 @@ +// The measurement loop, against a page of its own served in-process: an +// "editor" that shows a skeleton, then a contenteditable, behind a tour that +// has to be skipped, and mirrors what is typed into a second tab through a +// BroadcastChannel — enough to check what the canary times, and that it skips +// the tour and reports the steps that fail. +import { createServer } from 'node:http'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { chromium } from 'playwright'; +import type { Browser } from 'playwright'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { Pair, documentFor } from '../src/canary.js'; +import { parseConfig } from '../src/config.js'; +import type { Config } from '../src/config.js'; +import { registry } from '../src/metrics.js'; +import { Samples } from '../src/stats.js'; + +// The two tabs are separate browser contexts, so the text travels through the +// server: typed text is posted, and the other tab polls for it. +const PAGE = ( + readyAfterMs: number, + withTour: boolean, +) => ` +

Doc

+
loading
+${withTour ? '
' : ''} +`; + +describe('Pair', () => { + let browser: Browser; + let server: Server; + let url: string; + let readyAfterMs = 300; + let withTour = true; + + beforeAll(async () => { + browser = await chromium.launch(); + const texts = new Map(); + server = createServer((req, res) => { + const url = new URL(req.url ?? '/', 'http://x'); + if (url.pathname.startsWith('/text/')) { + const set = url.searchParams.get('set'); + if (set !== null) texts.set(url.pathname, set); + return res + .writeHead(200, { 'content-type': 'text/plain' }) + .end(texts.get(url.pathname) ?? ''); + } + if (!url.pathname.startsWith('/docs/')) return res.writeHead(404).end(); + res + .writeHead(200, { 'content-type': 'text/html' }) + .end(PAGE(readyAfterMs, withTour)); + }); + await new Promise((resolve) => + server.listen(0, '127.0.0.1', resolve), + ); + url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + }); + afterAll(async () => { + await browser.close(); + await new Promise((resolve) => server.close(resolve)); + }); + + const config = (...extra: string[]): Config => + parseConfig([ + '--url', + url, + '--storage-state', + '', + '--manifest', + 'm', + '--doc', + 'd1', + '--timeout', + '5', + '--metrics-port', + '0', + ...extra, + ]); + const samples = () => ({ + pageOpen: new Samples(), + editorReady: new Samples(), + propagation: new Samples(), + }); + + it('times the open, the editor and the propagation, skipping the tour', async () => { + const s = samples(); + const pair = new Pair({ + id: 0, + config: config(), + session: { + user_id: 'u', + session_key: 'k', + editable_documents: [], + readonly_documents: [], + }, + cookieName: 'docs_sessionid', + doc: 'd1', + samples: s, + log: () => {}, + }); + await pair.open(browser); + const result = await pair.iterate(); + await pair.close(); + + expect(result.failedStep).toBeUndefined(); + expect(result.pageOpen).toBeGreaterThan(0); + expect(result.editorReady).toBeGreaterThanOrEqual(0.3); + expect(result.propagation).toBeGreaterThan(0); + expect(result.propagation).toBeLessThan(3); + expect(s.propagation.count).toBe(1); + expect(await registry.metrics()).toMatch( + /canary_iterations_total\{result="ok"\} 1/, + ); + }); + + it('sends the session and the csrf cookies', async () => { + let cookie = ''; + const seen = createServer((req, res) => { + cookie = req.headers.cookie ?? ''; + res.writeHead(200, { 'content-type': 'text/html' }).end(PAGE(10, false)); + }); + await new Promise((resolve) => seen.listen(0, '127.0.0.1', resolve)); + const seenUrl = `http://127.0.0.1:${(seen.address() as AddressInfo).port}`; + const pair = new Pair({ + id: 1, + config: parseConfig([ + '--url', + seenUrl, + '--manifest', + 'm', + '--doc', + 'd', + '--timeout', + '5', + '--metrics-port', + '0', + ]), + session: { + user_id: 'u', + session_key: 'the-key', + editable_documents: [], + readonly_documents: [], + }, + cookieName: 'docs_sessionid', + doc: 'd', + samples: samples(), + log: () => {}, + }); + await pair.open(browser); + await pair.close(); + await new Promise((resolve) => seen.close(resolve)); + expect(cookie).toContain('docs_sessionid=the-key'); + expect(cookie).toMatch(/csrftoken=[A-Za-z0-9]{32}/); + }); + + it('reports the step that failed', async () => { + readyAfterMs = 60000; + withTour = false; + const pair = new Pair({ + id: 2, + config: config('--timeout', '2'), + session: null, + cookieName: '', + doc: 'd2', + samples: samples(), + log: () => {}, + }); + await expect(pair.open(browser)).rejects.toThrow(); + readyAfterMs = 300; + await pair.close(); + }); + + it('picks the document of the pair', () => { + const manifest = { + cookie_name: 'c', + public_documents: ['p'], + sessions: [], + }; + const session = { + user_id: 'u', + session_key: 'k', + editable_documents: ['e1', 'e2'], + readonly_documents: [], + }; + expect(documentFor(config(), session, manifest, 0)).toBe('d1'); + const noDoc = parseConfig([ + '--url', + url, + '--manifest', + 'm', + '--metrics-port', + '0', + ]); + expect(documentFor(noDoc, session, manifest, 0)).toBe('e1'); + expect(documentFor(noDoc, session, manifest, 1)).toBe('e2'); + expect( + documentFor(noDoc, { ...session, editable_documents: [] }, manifest, 0), + ).toBe('p'); + expect(documentFor(noDoc, null, manifest, 0)).toBeNull(); + }); +}); diff --git a/src/loadtest/canary/__tests__/config.spec.ts b/src/loadtest/canary/__tests__/config.spec.ts new file mode 100644 index 000000000..baafd4697 --- /dev/null +++ b/src/loadtest/canary/__tests__/config.spec.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import { parseConfig } from '../src/config.js'; + +const base = ['--manifest', 'm.json', '--url', 'https://docs.example.com/']; + +describe('parseConfig', () => { + it('has defaults', () => { + const config = parseConfig(base); + expect(config.url).toBe('https://docs.example.com'); + expect(config.pairs).toBe(2); + expect(config.duration).toBe(300); + expect(config.interval).toBe(5); + expect(config.timeout).toBe(30); + expect(config.headless).toBe(true); + expect(config.metricsPort).toBe(9466); + }); + + it('reads every option', () => { + const config = parseConfig([ + ...base, + '--pairs', + '4', + '--duration', + '60', + '--interval', + '0', + '--doc', + 'd', + '--timeout', + '10', + '--headed', + '--metrics-port', + '0', + '--metrics-token', + 't', + '--report', + 'r.json', + '--screenshots', + 'shots', + ]); + expect(config).toMatchObject({ + pairs: 4, + duration: 60, + interval: 0, + doc: 'd', + timeout: 10, + headless: false, + metricsPort: 0, + metricsToken: 't', + report: 'r.json', + screenshots: 'shots', + }); + }); + + it('accepts a storage state in place of the manifest, with a document', () => { + const config = parseConfig([ + '--url', + 'https://x', + '--storage-state', + 's.json', + '--doc', + 'd', + ]); + expect(config.manifest).toBe(''); + expect(config.storageState).toBe('s.json'); + }); + + it.each([ + [['--url', 'https://x'], '--manifest is required'], + [ + ['--url', 'https://x', '--storage-state', 's'], + '--storage-state needs --doc', + ], + [['--manifest', 'm'], '--url is required'], + [['--manifest', 'm', '--url', 'wss://x'], 'must be http:// or https://'], + [[...base, '--pairs', '0'], '--pairs must be a number'], + [[...base, '--unknown'], 'Unknown option'], + ])('refuses %j', (argv, message) => { + expect(() => parseConfig(argv)).toThrow(message); + }); +}); diff --git a/src/loadtest/canary/__tests__/manifest.spec.ts b/src/loadtest/canary/__tests__/manifest.spec.ts new file mode 100644 index 000000000..3748b1acf --- /dev/null +++ b/src/loadtest/canary/__tests__/manifest.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { parseManifest } from '../src/manifest.js'; + +const valid = { + cookie_name: 'docs_sessionid', + public_documents: ['p1'], + sessions: [ + { + user_id: 'u1', + session_key: 'k1', + editable_documents: ['e1'], + readonly_documents: ['r1'], + }, + ], +}; + +describe('parseManifest', () => { + it('reads what the backend writes', () => { + const manifest = parseManifest(JSON.stringify(valid)); + expect(manifest.cookie_name).toBe('docs_sessionid'); + expect(manifest.sessions[0]).toEqual(valid.sessions[0]); + expect(manifest.public_documents).toEqual(['p1']); + }); + + it('fills what is missing rather than failing on it', () => { + const manifest = parseManifest( + JSON.stringify({ cookie_name: 'c', sessions: [{ session_key: 'k' }] }), + ); + expect(manifest.sessions[0]).toEqual({ + user_id: '0', + session_key: 'k', + editable_documents: [], + readonly_documents: [], + }); + expect(manifest.public_documents).toEqual([]); + }); + + it.each([ + ['not json', 'not JSON'], + ['null', 'not an object'], + ['[]', 'no cookie_name'], + ['{"sessions":[{"session_key":"k"}]}', 'no cookie_name'], + ['{"cookie_name":"c","sessions":[]}', 'holds no session'], + ['{"cookie_name":"c","sessions":[{"user_id":"u"}]}', 'no session_key'], + ])('refuses %s', (text, message) => { + expect(() => parseManifest(text)).toThrow(message); + }); + + it('refuses sessions that have expired', () => { + expect(() => + parseManifest( + JSON.stringify({ ...valid, expires_at: '2000-01-01T00:00:00+00:00' }), + ), + ).toThrow('expired'); + expect(() => + parseManifest( + JSON.stringify({ ...valid, expires_at: '2999-01-01T00:00:00+00:00' }), + ), + ).not.toThrow(); + }); +}); diff --git a/src/loadtest/canary/__tests__/stats.spec.ts b/src/loadtest/canary/__tests__/stats.spec.ts new file mode 100644 index 000000000..799110033 --- /dev/null +++ b/src/loadtest/canary/__tests__/stats.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { Samples } from '../src/stats.js'; + +describe('Samples', () => { + it('summarises nothing as null', () => { + expect(new Samples().summary()).toBeNull(); + }); + + it('computes the percentiles', () => { + const samples = new Samples(); + for (let i = 1; i <= 100; i++) samples.add(i); + expect(samples.summary()).toEqual({ + count: 100, + min: 1, + p50: 50, + p95: 95, + p99: 99, + max: 100, + mean: 50.5, + }); + }); +}); diff --git a/src/loadtest/canary/eslint.config.mjs b/src/loadtest/canary/eslint.config.mjs new file mode 100644 index 000000000..6c91b6a90 --- /dev/null +++ b/src/loadtest/canary/eslint.config.mjs @@ -0,0 +1,24 @@ +import js from '@eslint/js'; +import prettier from 'eslint-config-prettier/flat'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['dist/**', 'node_modules/**'], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { varsIgnorePattern: '^_', argsIgnorePattern: '^_' }, + ], + }, + }, + prettier, +); diff --git a/src/loadtest/canary/package.json b/src/loadtest/canary/package.json new file mode 100644 index 000000000..be64d62c7 --- /dev/null +++ b/src/loadtest/canary/package.json @@ -0,0 +1,30 @@ +{ + "name": "docs-loadtest-canary", + "private": true, + "type": "module", + "description": "A few real browsers opening and editing documents in a loop, measuring what a user would feel while the load generators run", + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "start": "node dist/index.js", + "lint": "eslint", + "test": "vitest run" + }, + "engines": { + "node": ">=22" + }, + "packageManager": "yarn@1.22.22", + "dependencies": { + "playwright": "1.62.1", + "prom-client": "15.1.3" + }, + "devDependencies": { + "@eslint/js": "10.0.1", + "@types/node": "24.13.3", + "eslint": "10.9.0", + "eslint-config-prettier": "10.1.8", + "typescript": "6.0.3", + "typescript-eslint": "8.69.0", + "vitest": "3.2.7" + } +} diff --git a/src/loadtest/canary/src/canary.ts b/src/loadtest/canary/src/canary.ts new file mode 100644 index 000000000..f1e45c037 --- /dev/null +++ b/src/loadtest/canary/src/canary.ts @@ -0,0 +1,250 @@ +/** + * One pair of browsers on one document: a writer and a reader. In a loop, the + * writer opens the document, waits for the editor, types a unique token; the + * reader, already on the document, waits for that token to show. What is + * timed is what a user would feel — from the navigation to the editor being + * usable, and from a keystroke to its arrival on another screen — through the + * real frontend, the real editor and the real collaboration path. + */ +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +import type { Browser, BrowserContext, Page } from 'playwright'; + +import type { Config } from './config.js'; +import type { Manifest, Session } from './manifest.js'; +import * as metrics from './metrics.js'; +import type { Samples } from './stats.js'; + +const TITLE = '[aria-label="Document title"], input[name="title"], h1'; +const EDITOR = '.--docs--editor-container .ProseMirror'; +// where the e2e helpers click to type at the end of the document +const TRAILING_BLOCK = '.bn-trailing-block.ProseMirror-widget'; +// 32 alphanumeric characters, what Django accepts as a CSRF secret +const CSRF_TOKEN = 'canaryloadtestcanaryloadtest0000'; + +// A user who never opened Docs is shown a tour on the first document. It sits +// over the editor: skipped when it shows, as that user would. +const dismissOnboarding = async (page: Page): Promise => { + const skip = page.getByRole('button', { name: 'Skip' }); + if (await skip.isVisible().catch(() => false)) { + await skip.click().catch(() => {}); + await skip.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {}); + } +}; + +/** + * Put the caret at the end of the document, the way the e2e helpers do. A click + * the tour intercepts is retried once the tour is skipped, until `timeoutMs`. + */ +const focusEnd = async (page: Page, timeoutMs: number): Promise => { + const editor = page.locator(EDITOR).first(); + const deadline = Date.now() + timeoutMs; + for (;;) { + await dismissOnboarding(page); + try { + const trailing = editor.locator(TRAILING_BLOCK); + if ((await trailing.count()) > 0) await trailing.click({ timeout: 3000 }); + else await editor.click({ timeout: 3000 }); + break; + } catch (err) { + if (Date.now() > deadline) throw err; + } + } + await page.keyboard.press('Control+End'); +}; + +export interface PairOptions { + id: number; + config: Config; + session: Session | null; + cookieName: string; + doc: string; + samples: { pageOpen: Samples; editorReady: Samples; propagation: Samples }; + log: (line: string) => void; +} + +export interface IterationResult { + pageOpen?: number; + editorReady?: number; + propagation?: number; + failedStep?: string; + error?: string; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** The document every browser of the run opens for a pair: `--doc`, else the user's. */ +export const documentFor = ( + config: Config, + session: Session | null, + manifest: Manifest | null, + pairId: number, +): string | null => { + if (config.doc) return config.doc; + if (!session) return null; + const own = session.editable_documents; + const pool = own.length > 0 ? own : (manifest?.public_documents ?? []); + return pool.length > 0 ? pool[pairId % pool.length] : null; +}; + +export class Pair { + readonly options: PairOptions; + private writer: BrowserContext | null = null; + private reader: BrowserContext | null = null; + private readerPage: Page | null = null; + private seq = 0; + + constructor(options: PairOptions) { + this.options = options; + } + + private async context( + browser: Browser, + role: string, + ): Promise { + const { config, session, cookieName } = this.options; + const context = await browser.newContext({ + ...(config.storageState ? { storageState: config.storageState } : {}), + locale: 'en-US', + viewport: { width: 1280, height: 800 }, + ignoreHTTPSErrors: true, + }); + if (session) { + const url = new URL(config.url); + // what a login would have left: the session, on the host of the + // application, which is the host of its API as well (a cookie ignores + // the port, so the dev stack's :3000 and :8071 share it) + const cookie = { + domain: url.hostname, + path: '/', + secure: url.protocol === 'https:', + sameSite: 'Lax' as const, + }; + await context.addCookies([ + { + ...cookie, + name: cookieName, + value: session.session_key, + httpOnly: true, + }, + // Django's double-submit CSRF token, which the frontend reads to sign + // its POSTs (the title, "onboarding done", ...). A login leaves one + // behind; any value does as long as the cookie and the header agree. + { ...cookie, name: 'csrftoken', value: CSRF_TOKEN, httpOnly: false }, + ]); + } + context.on('page', (page) => { + page.on('console', (message) => { + if (message.type() === 'error') metrics.consoleErrors.inc(); + }); + page.on('pageerror', () => metrics.consoleErrors.inc()); + }); + context.setDefaultTimeout(config.timeout * 1000); + void role; + return context; + } + + async open(browser: Browser): Promise { + this.writer = await this.context(browser, 'writer'); + this.reader = await this.context(browser, 'reader'); + // the reader stays on the document for the whole run, like a colleague + // who has the page open: it only measures propagation + this.readerPage = await this.reader.newPage(); + await this.readerPage.goto( + `${this.options.config.url}/docs/${this.options.doc}/`, + ); + await this.readerPage.locator(EDITOR).first().waitFor({ state: 'visible' }); + await dismissOnboarding(this.readerPage); + } + + /** One iteration: open, wait for the editor, type, watch the reader. */ + async iterate(): Promise { + const { config, doc } = this.options; + if (!this.writer || !this.readerPage) throw new Error('pair not opened'); + const result: IterationResult = {}; + const page = await this.writer.newPage(); + try { + const started = Date.now(); + await page.goto(`${config.url}/docs/${doc}/`, { waitUntil: 'commit' }); + await page + .locator(TITLE) + .first() + .waitFor({ state: 'visible' }) + .catch((err) => { + result.failedStep = 'page-open'; + throw err; + }); + result.pageOpen = (Date.now() - started) / 1000; + metrics.pageOpen.observe(result.pageOpen); + this.options.samples.pageOpen.add(result.pageOpen); + + const editor = page.locator(`${EDITOR}[contenteditable="true"]`).first(); + await editor.waitFor({ state: 'visible' }).catch((err) => { + result.failedStep = 'editor-ready'; + throw err; + }); + result.editorReady = (Date.now() - started) / 1000; + metrics.editorReady.observe(result.editorReady); + this.options.samples.editorReady.add(result.editorReady); + + // type at the end of the document, where nobody is + this.seq += 1; + const token = `canary-${this.options.id}-${process.pid}-${this.seq}`; + await dismissOnboarding(page); + await focusEnd(page, config.timeout * 1000).catch((err) => { + result.failedStep = 'focus'; + throw err; + }); + const typed = Date.now(); + await page.keyboard.type(token); + await this.readerPage + .locator(EDITOR) + .first() + .getByText(token) + .first() + .waitFor({ state: 'visible' }) + .catch((err) => { + result.failedStep = 'propagation'; + throw err; + }); + result.propagation = (Date.now() - typed) / 1000; + metrics.propagation.observe(result.propagation); + this.options.samples.propagation.add(result.propagation); + + // leave the document as it was found + for (let i = 0; i < token.length; i++) + await page.keyboard.press('Backspace'); + await sleep(200); + metrics.iterations.inc({ result: 'ok' }); + } catch (err) { + result.failedStep = result.failedStep ?? 'unknown'; + result.error = + err instanceof Error ? err.message.split('\n')[0] : String(err); + metrics.iterations.inc({ result: 'failed' }); + metrics.failures.inc({ step: result.failedStep }); + if (config.screenshots) { + mkdirSync(config.screenshots, { recursive: true }); + await page + .screenshot({ + path: join( + config.screenshots, + `pair${this.options.id}-${Date.now()}-${result.failedStep}.png`, + ), + }) + .catch(() => {}); + } + } finally { + await page.close().catch(() => {}); + } + return result; + } + + async close(): Promise { + await this.reader?.close().catch(() => {}); + await this.writer?.close().catch(() => {}); + this.reader = null; + this.writer = null; + this.readerPage = null; + } +} diff --git a/src/loadtest/canary/src/config.ts b/src/loadtest/canary/src/config.ts new file mode 100644 index 000000000..d589ff784 --- /dev/null +++ b/src/loadtest/canary/src/config.ts @@ -0,0 +1,103 @@ +/** + * Everything the canary is told, from the command line, validated once here. + * + * canary --manifest m.json --url https://docs.example.com --pairs 3 --duration 600 + */ +import { parseArgs } from 'node:util'; + +export interface Config { + manifest: string; + /** The frontend, `https://host`. */ + url: string; + /** Pairs of browsers (a writer and a reader on one document). */ + pairs: number; + /** Seconds to run. */ + duration: number; + /** Seconds a pair waits between two iterations. */ + interval: number; + /** The document every pair opens; else each pair opens one of its user's. */ + doc?: string; + /** Seconds before an open or a propagation is given up on. */ + timeout: number; + headless: boolean; + /** Playwright's `storageState` file, in place of the manifest's cookies. */ + storageState?: string; + metricsPort: number; + metricsToken: string; + report: string; + /** Where screenshots of failed iterations go, empty for none. */ + screenshots: string; +} + +const number = ( + name: string, + raw: string | undefined, + dflt: number, + min: number, + max = Infinity, +): number => { + if (raw === undefined || raw === '') return dflt; + const value = Number(raw); + if (!Number.isFinite(value) || value < min || value > max) { + throw new Error( + `--${name} must be a number between ${min} and ${max} (got "${raw}")`, + ); + } + return value; +}; + +export const parseConfig = (argv: string[]): Config => { + const { values } = parseArgs({ + args: argv, + options: { + manifest: { type: 'string' }, + url: { type: 'string' }, + pairs: { type: 'string' }, + duration: { type: 'string' }, + interval: { type: 'string' }, + doc: { type: 'string' }, + timeout: { type: 'string' }, + headed: { type: 'boolean', default: false }, + 'storage-state': { type: 'string' }, + 'metrics-port': { type: 'string' }, + 'metrics-token': { type: 'string', default: '' }, + report: { type: 'string', default: '-' }, + screenshots: { type: 'string', default: '' }, + }, + strict: true, + }); + if (!values.manifest && !values['storage-state']) { + throw new Error('--manifest is required (or --storage-state with --doc)'); + } + if (!values.url) + throw new Error('--url is required (https://host of the frontend)'); + let url: URL; + try { + url = new URL(values.url); + } catch { + throw new Error(`--url is not a url (got "${values.url}")`); + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`--url must be http:// or https:// (got "${values.url}")`); + } + if (values['storage-state'] && !values.doc) { + throw new Error( + '--storage-state needs --doc: without the manifest there is no list of documents', + ); + } + return { + manifest: values.manifest ?? '', + url: values.url.replace(/\/+$/, ''), + pairs: number('pairs', values.pairs, 2, 1, 50), + duration: number('duration', values.duration, 300, 1), + interval: number('interval', values.interval, 5, 0), + doc: values.doc, + timeout: number('timeout', values.timeout, 30, 1), + headless: !values.headed, + storageState: values['storage-state'], + metricsPort: number('metrics-port', values['metrics-port'], 9466, 0, 65535), + metricsToken: values['metrics-token'] ?? '', + report: values.report ?? '-', + screenshots: values.screenshots ?? '', + }; +}; diff --git a/src/loadtest/canary/src/index.ts b/src/loadtest/canary/src/index.ts new file mode 100644 index 000000000..35f4d78a6 --- /dev/null +++ b/src/loadtest/canary/src/index.ts @@ -0,0 +1,147 @@ +#!/usr/bin/env node +/** + * The command line: a few pairs of real browsers, looping on the documents of + * the manifest for `--duration` seconds. Progress on stderr, the report on + * stdout (or `--report`), the metrics on `--metrics-port`. + */ +import { writeFileSync } from 'node:fs'; + +import { chromium } from 'playwright'; + +import { Pair, documentFor } from './canary.js'; +import type { IterationResult } from './canary.js'; +import { parseConfig } from './config.js'; +import { readManifest } from './manifest.js'; +import type { Manifest } from './manifest.js'; +import { pairsRunning, startMetricsServer } from './metrics.js'; +import { Samples } from './stats.js'; + +const log = (line: string) => + process.stderr.write(`${new Date().toISOString()} ${line}\n`); +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const main = async (): Promise => { + const config = parseConfig(process.argv.slice(2)); + const manifest: Manifest | null = config.manifest + ? readManifest(config.manifest) + : null; + if (config.metricsPort > 0) { + await startMetricsServer(config.metricsPort, config.metricsToken); + log(`metrics on :${config.metricsPort}/metrics`); + } + const samples = { + pageOpen: new Samples(), + editorReady: new Samples(), + propagation: new Samples(), + }; + const browser = await chromium.launch({ headless: config.headless }); + let stopping = false; + const stop = () => { + log('stopping'); + stopping = true; + }; + process.on('SIGINT', stop); + process.on('SIGTERM', stop); + + const pairs: Pair[] = []; + const warnings: string[] = []; + for (let id = 0; id < config.pairs; id++) { + const session = manifest + ? manifest.sessions[id % manifest.sessions.length] + : null; + const doc = documentFor(config, session, manifest, id); + if (!doc) { + warnings.push(`pair ${id}: no document to open, skipped`); + continue; + } + pairs.push( + new Pair({ + id, + config, + session, + cookieName: manifest?.cookie_name ?? '', + doc, + samples, + log, + }), + ); + } + if (manifest && config.pairs > manifest.sessions.length) { + warnings.push( + `${config.pairs} pairs for ${manifest.sessions.length} sessions: some users are logged in several times`, + ); + } + + const startedAt = new Date(); + const until = Date.now() + config.duration * 1000; + const results: IterationResult[] = []; + const runPair = async (pair: Pair) => { + try { + await pair.open(browser); + } catch (err) { + const message = + err instanceof Error ? err.message.split('\n')[0] : String(err); + warnings.push( + `pair ${pair.options.id}: could not open the document for the reader: ${message}`, + ); + log(`pair ${pair.options.id}: ${message}`); + return; + } + pairsRunning.inc(); + try { + while (!stopping && Date.now() < until) { + const result = await pair.iterate(); + results.push(result); + log( + `pair ${pair.options.id}: ` + + (result.failedStep + ? `FAILED at ${result.failedStep}: ${result.error}` + : `open ${result.pageOpen?.toFixed(2)}s, ready ${result.editorReady?.toFixed(2)}s, propagation ${result.propagation?.toFixed(2)}s`), + ); + await sleep(config.interval * 1000); + } + } finally { + pairsRunning.dec(); + await pair.close(); + } + }; + log( + `starting: ${pairs.length} pair(s) on ${new Set(pairs.map((p) => p.options.doc)).size} document(s), for ${config.duration}s`, + ); + await Promise.all(pairs.map(runPair)); + await browser.close(); + + const { metricsToken: _token, ...publicConfig } = config; + const failed = results.filter((r) => r.failedStep); + const report = { + config: publicConfig, + startedAt: startedAt.toISOString(), + endedAt: new Date().toISOString(), + pairs: pairs.length, + iterations: results.length, + failed: failed.length, + failedSteps: failed.reduce>((acc, r) => { + acc[r.failedStep ?? 'unknown'] = + (acc[r.failedStep ?? 'unknown'] ?? 0) + 1; + return acc; + }, {}), + pageOpen: samples.pageOpen.summary(), + editorReady: samples.editorReady.summary(), + propagation: samples.propagation.summary(), + warnings, + }; + const text = JSON.stringify(report, null, 2); + if (config.report === '-') process.stdout.write(`${text}\n`); + else writeFileSync(config.report, text); + for (const warning of warnings) log(`warning: ${warning}`); + log(`done: ${results.length} iterations, ${failed.length} failed`); + return results.length > 0 && failed.length === 0 ? 0 : 1; +}; + +main().then( + (code) => process.exit(code), + (err) => { + process.stderr.write(`${err instanceof Error ? err.message : err}\n`); + process.exit(2); + }, +); diff --git a/src/loadtest/canary/src/manifest.ts b/src/loadtest/canary/src/manifest.ts new file mode 100644 index 000000000..6bd60958f --- /dev/null +++ b/src/loadtest/canary/src/manifest.ts @@ -0,0 +1,80 @@ +/** + * The manifest `create_load_test_sessions` writes (src/backend/loadtest): which + * cookie to send, and which documents each of the logged-in users may open. + */ +import { readFileSync } from 'node:fs'; + +export interface Session { + user_id: string; + session_key: string; + editable_documents: string[]; + readonly_documents: string[]; +} + +export interface Manifest { + cookie_name: string; + expires_at?: string; + public_documents: string[]; + sessions: Session[]; +} + +const isStringArray = (value: unknown): value is string[] => + Array.isArray(value) && value.every((item) => typeof item === 'string'); + +export const parseManifest = (text: string): Manifest => { + let data: unknown; + try { + data = JSON.parse(text); + } catch (err) { + throw new Error( + `The manifest is not JSON: ${err instanceof Error ? err.message : err}`, + { cause: err }, + ); + } + if (typeof data !== 'object' || data === null) { + throw new Error('The manifest is not an object'); + } + const manifest = data as Record; + if (typeof manifest.cookie_name !== 'string' || !manifest.cookie_name) { + throw new Error('The manifest has no cookie_name'); + } + if (!Array.isArray(manifest.sessions) || manifest.sessions.length === 0) { + throw new Error('The manifest holds no session'); + } + const sessions = manifest.sessions.map((session, index): Session => { + const s = session as Record; + if (typeof s.session_key !== 'string' || !s.session_key) { + throw new Error(`Session #${index} has no session_key`); + } + return { + user_id: typeof s.user_id === 'string' ? s.user_id : String(index), + session_key: s.session_key, + editable_documents: isStringArray(s.editable_documents) + ? s.editable_documents + : [], + readonly_documents: isStringArray(s.readonly_documents) + ? s.readonly_documents + : [], + }; + }); + if (manifest.expires_at !== undefined) { + const expires = Date.parse(String(manifest.expires_at)); + if (!Number.isNaN(expires) && expires < Date.now()) { + throw new Error( + `The sessions of the manifest expired at ${manifest.expires_at}`, + ); + } + } + return { + cookie_name: manifest.cookie_name, + expires_at: + typeof manifest.expires_at === 'string' ? manifest.expires_at : undefined, + public_documents: isStringArray(manifest.public_documents) + ? manifest.public_documents + : [], + sessions, + }; +}; + +export const readManifest = (path: string): Manifest => + parseManifest(readFileSync(path, 'utf8')); diff --git a/src/loadtest/canary/src/metrics.ts b/src/loadtest/canary/src/metrics.ts new file mode 100644 index 000000000..36e2ca098 --- /dev/null +++ b/src/loadtest/canary/src/metrics.ts @@ -0,0 +1,104 @@ +/** + * What the canary measures, served on /metrics for the Prometheus of the + * campaign and summarised in the final report: the numbers a user would feel. + */ +import { timingSafeEqual } from 'node:crypto'; +import type { IncomingMessage, Server, ServerResponse } from 'node:http'; +import { createServer } from 'node:http'; + +import { + Counter, + Gauge, + Histogram, + Registry, + collectDefaultMetrics, +} from 'prom-client'; + +export const registry = new Registry(); + +const SECONDS = [ + 0.1, 0.25, 0.5, 0.75, 1, 1.5, 2, 3, 5, 7.5, 10, 15, 20, 30, 60, +]; + +export const pageOpen = new Histogram({ + name: 'canary_page_open_seconds', + help: 'From the navigation to the editor being visible', + buckets: SECONDS, + registers: [registry], +}); +export const editorReady = new Histogram({ + name: 'canary_editor_ready_seconds', + help: 'From the navigation to the editor accepting input (the collaboration provider synced)', + buckets: SECONDS, + registers: [registry], +}); +export const propagation = new Histogram({ + name: 'canary_propagation_seconds', + help: 'From a keystroke in one browser to the text showing in another browser on the same document', + buckets: SECONDS, + registers: [registry], +}); +export const iterations = new Counter({ + name: 'canary_iterations_total', + help: 'Iterations, by result', + labelNames: ['result'] as const, + registers: [registry], +}); +export const failures = new Counter({ + name: 'canary_failures_total', + help: 'What failed, by step', + labelNames: ['step'] as const, + registers: [registry], +}); +export const consoleErrors = new Counter({ + name: 'canary_console_errors_total', + help: 'Errors the pages logged to their console', + registers: [registry], +}); +export const pairsRunning = new Gauge({ + name: 'canary_pairs', + help: 'Browser pairs running', + registers: [registry], +}); + +const authorized = (req: IncomingMessage, token: string): boolean => { + if (!token) return true; + const presented = Buffer.from(req.headers.authorization ?? ''); + const expected = Buffer.from(`Bearer ${token}`); + return ( + presented.length === expected.length && timingSafeEqual(presented, expected) + ); +}; + +export const handleMetricsRequest = async ( + req: IncomingMessage, + res: ServerResponse, + token: string, +): Promise => { + if ((req.url ?? '').split('?')[0] !== '/metrics') { + res.writeHead(404).end(); + return; + } + if (!authorized(req, token)) { + res.writeHead(401, { 'www-authenticate': 'Bearer' }).end(); + return; + } + res + .writeHead(200, { 'content-type': registry.contentType }) + .end(await registry.metrics()); +}; + +export const startMetricsServer = ( + port: number, + token: string, +): Promise => { + collectDefaultMetrics({ register: registry }); + const server = createServer((req, res) => { + void handleMetricsRequest(req, res, token); + }); + server.unref(); + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, () => resolve(server)); + }); +}; diff --git a/src/loadtest/canary/src/stats.ts b/src/loadtest/canary/src/stats.ts new file mode 100644 index 000000000..dcd4ced68 --- /dev/null +++ b/src/loadtest/canary/src/stats.ts @@ -0,0 +1,44 @@ +/** Percentiles of what was measured, for the final report. */ + +export interface Summary { + count: number; + min: number; + p50: number; + p95: number; + p99: number; + max: number; + mean: number; +} + +export class Samples { + private values: number[] = []; + + add(value: number): void { + this.values.push(value); + } + + get count(): number { + return this.values.length; + } + + summary(): Summary | null { + if (this.values.length === 0) return null; + const sorted = [...this.values].sort((a, b) => a - b); + const at = (q: number): number => + sorted[ + Math.min( + sorted.length - 1, + Math.max(0, Math.ceil(q * sorted.length) - 1), + ) + ]; + return { + count: sorted.length, + min: sorted[0], + p50: at(0.5), + p95: at(0.95), + p99: at(0.99), + max: sorted[sorted.length - 1], + mean: sorted.reduce((sum, value) => sum + value, 0) / sorted.length, + }; + } +} diff --git a/src/loadtest/canary/tsconfig.build.json b/src/loadtest/canary/tsconfig.build.json new file mode 100644 index 000000000..4559ad96b --- /dev/null +++ b/src/loadtest/canary/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "noEmit": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "__tests__"] +} diff --git a/src/loadtest/canary/tsconfig.json b/src/loadtest/canary/tsconfig.json new file mode 100644 index 000000000..7bb657bea --- /dev/null +++ b/src/loadtest/canary/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["es2023"], + // the tool runs the compiled JS straight through Node's ESM loader, so + // resolution has to be Node's — import specifiers keep their `.js` + // extension, as they already did in the JS sources. + "module": "nodenext", + "moduleResolution": "nodenext", + "types": ["node"], + "strict": true, + "noImplicitOverride": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "outDir": "./dist", + "sourceMap": true + }, + "include": ["src/**/*.ts", "__tests__/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/src/loadtest/canary/vitest.config.mts b/src/loadtest/canary/vitest.config.mts new file mode 100644 index 000000000..fd8e066f2 --- /dev/null +++ b/src/loadtest/canary/vitest.config.mts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +// `__tests__/*.spec.ts`: unit tests of the pure parts, and one integration test +// running a swarm against an in-process y-websocket server (`_server.ts`). +export default defineConfig({ + test: { + include: ['__tests__/**/*.spec.ts'], + testTimeout: 30000, + clearMocks: true, + }, +}); diff --git a/src/loadtest/canary/yarn.lock b/src/loadtest/canary/yarn.lock new file mode 100644 index 000000000..259b57a58 --- /dev/null +++ b/src/loadtest/canary/yarn.lock @@ -0,0 +1,1303 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@esbuild/aix-ppc64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz#bf6e10303bcf2e7c686975fa52f937ec2728d8bc" + integrity sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ== + +"@esbuild/android-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz#0c6246bc8d2c4d172aac2db3fb1190d72bd65504" + integrity sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A== + +"@esbuild/android-arm@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz#2d84ece6a4e2684d92be26ee13d42757d831c381" + integrity sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg== + +"@esbuild/android-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz#fc38d4d6358d8dc1cf53f09f7589fe436eb64801" + integrity sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q== + +"@esbuild/darwin-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz#f83afeeac1d7dac01c7a2fd012b3e451a0591fcc" + integrity sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw== + +"@esbuild/darwin-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz#510147c055a795588dbbe14fd6b1b8ad0a2f30de" + integrity sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw== + +"@esbuild/freebsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz#093b9200ecf0b115ba4e5e248a7485c9c5f8bd5e" + integrity sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw== + +"@esbuild/freebsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz#0be22b6df925d213e841ea87123af5df80b0faf7" + integrity sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg== + +"@esbuild/linux-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz#1bdbc651cda9ba9995c53ed9c71ceaa65094762d" + integrity sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug== + +"@esbuild/linux-arm@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz#beb12ad72b84f72d28488cc1b8ee9f7eb141d753" + integrity sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w== + +"@esbuild/linux-ia32@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz#b81f9d55529b45c206a46a138214b1aa6879696b" + integrity sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ== + +"@esbuild/linux-loong64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz#598667241a04c99b76ed6ef940ac50038c419f98" + integrity sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ== + +"@esbuild/linux-mips64el@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz#1c51eb9cea903f53d97b5af3b1841db70f5596ca" + integrity sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA== + +"@esbuild/linux-ppc64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz#63dd61f17ceb31a81227f413feac8a71bc2c51f2" + integrity sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ== + +"@esbuild/linux-riscv64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz#3763b08fde5cf25ab1facb8e7752edfe45fbfc27" + integrity sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA== + +"@esbuild/linux-s390x@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz#1a137ff293a82906eb3176385bd7e8e0e5cfb7cb" + integrity sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg== + +"@esbuild/linux-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz#268b36211c146ca54f8fe12c578a8d6ef8979485" + integrity sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ== + +"@esbuild/netbsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz#22571ad951d62bb6accc82d8d1fad5c8c1ac0ba1" + integrity sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw== + +"@esbuild/netbsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz#42fcc57297eb0a0ca3f5fc475291f4c1a3f7c0de" + integrity sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw== + +"@esbuild/openbsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz#9eb32af104ac3dacf4edca01f596664aab0c73ef" + integrity sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ== + +"@esbuild/openbsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz#febed2402d6088225e91f20fb4ce2522ad0a4efd" + integrity sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw== + +"@esbuild/openharmony-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz#85641c3d466428bfbccea5f21c26836663fef5ce" + integrity sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q== + +"@esbuild/sunos-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz#a736f9d8962481045fc4c3e54f5479f22c870fb4" + integrity sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g== + +"@esbuild/win32-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz#ee5ab40fad186201b652a33f8a5eb149e9e42532" + integrity sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ== + +"@esbuild/win32-ia32@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz#c40d28a6d99a127da6711f2afd74b11cb63b06a7" + integrity sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA== + +"@esbuild/win32-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz#b21affb804cc167c133d95f45b3a1dc1323b9a87" + integrity sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g== + +"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": + version "4.10.1" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz#8911bd72b2c3640a543609e0400b8c4d2e7e7cb6" + integrity sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.12.2": + version "4.12.2" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" + integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== + +"@eslint/config-array@^0.23.5": + version "0.23.5" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.23.5.tgz#56e86d243049195d8acc0c06a1b3dfdc3fa3de95" + integrity sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA== + dependencies: + "@eslint/object-schema" "^3.0.5" + debug "^4.3.1" + minimatch "^10.2.4" + +"@eslint/config-helpers@^0.7.0": + version "0.7.0" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.7.0.tgz#09ee4aa07b73f059ec2d4c74bf4b2ff02b322377" + integrity sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw== + dependencies: + "@eslint/core" "^1.2.1" + +"@eslint/core@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.2.1.tgz#c1da7cd1b82fa8787f98b5629fb811848a1b63ce" + integrity sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ== + dependencies: + "@types/json-schema" "^7.0.15" + +"@eslint/js@10.0.1": + version "10.0.1" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-10.0.1.tgz#1e8a876f50117af8ab67e47d5ad94d38d6622583" + integrity sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA== + +"@eslint/object-schema@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-3.0.5.tgz#88e9bf4d11d2b19c082e78ebe7ce88724a5eb091" + integrity sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw== + +"@eslint/plugin-kit@^0.7.2": + version "0.7.3" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz#cc7268cc36405b331ef92db1bc37971f21d66fe1" + integrity sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q== + dependencies: + "@eslint/core" "^1.2.1" + levn "^0.4.1" + +"@humanfs/core@^0.19.2": + version "0.19.2" + resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.2.tgz#a8272ca03b2acf492670222b2320b6c421bfde60" + integrity sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA== + dependencies: + "@humanfs/types" "^0.15.0" + +"@humanfs/node@^0.16.6": + version "0.16.8" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.8.tgz#8f800cccc13f4f8cd3116e2d9c0a94939da3e3ed" + integrity sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ== + dependencies: + "@humanfs/core" "^0.19.2" + "@humanfs/types" "^0.15.0" + "@humanwhocodes/retry" "^0.4.0" + +"@humanfs/types@^0.15.0": + version "0.15.0" + resolved "https://registry.yarnpkg.com/@humanfs/types/-/types-0.15.0.tgz#f2a09f62012390b2bff3fc6fb248ddec8c09a090" + integrity sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q== + +"@humanwhocodes/module-importer@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" + integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== + +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== + +"@jridgewell/sourcemap-codec@^1.5.5": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz#f4c663e862f06dc98ca4d453862c46902789a18d" + integrity sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw== + +"@napi-rs/lzma-linux-x64-gnu@1.5.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz#e57d4306966078662038094fb38eb9146dc3aea9" + integrity sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ== + +"@opentelemetry/api@^1.4.0": + version "1.9.1" + resolved "https://registry.yarnpkg.com/@opentelemetry/api/-/api-1.9.1.tgz#c1b0346de336ba55af2d5a7970882037baedec05" + integrity sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q== + +"@rollup/rollup-android-arm-eabi@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz#e1d06062fb451578b3fb0bdea3360daaa1395f44" + integrity sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw== + +"@rollup/rollup-android-arm64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz#2e81dec4e9e879c95c8266e30da67074c4998224" + integrity sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw== + +"@rollup/rollup-darwin-arm64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz#f48f92f44454aed23d013f7cdb6da0dc2cb598ae" + integrity sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA== + +"@rollup/rollup-darwin-x64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz#39b0b2ad27c49b40aec71fb20210e2e3be5fdbd0" + integrity sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw== + +"@rollup/rollup-freebsd-arm64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz#0c85d18f5b9228b7195adbbbc97c1adaf41b8106" + integrity sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw== + +"@rollup/rollup-freebsd-x64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz#0c6c27471ea950213aad76418f075c96f57e9533" + integrity sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw== + +"@rollup/rollup-linux-arm-gnueabihf@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz#28cba40d417fb3d2c1536941b02143a2bdda72be" + integrity sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw== + +"@rollup/rollup-linux-arm-musleabihf@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz#c9f06aa876d6d312012a2048d9e5533c581a7341" + integrity sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA== + +"@rollup/rollup-linux-arm64-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz#b26376804ca32b3dcc8f68930f765394fb4e4814" + integrity sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg== + +"@rollup/rollup-linux-arm64-musl@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz#65f6f5deee9d30904b4d52ee3d45679055743cd1" + integrity sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ== + +"@rollup/rollup-linux-loong64-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz#15d7e44538e967a3d6132f5b9f27f02346df6850" + integrity sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw== + +"@rollup/rollup-linux-loong64-musl@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz#7afad2b9e41941344883154f754b7f772ef16b01" + integrity sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA== + +"@rollup/rollup-linux-ppc64-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz#3f3131b1a9585851b2875c86a49f6e6deb9e68e0" + integrity sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA== + +"@rollup/rollup-linux-ppc64-musl@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz#463aeee26c62983c1fc235708d1b41baf4735f98" + integrity sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA== + +"@rollup/rollup-linux-riscv64-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz#cb495bd8cc978e17593e0d323b36fd95b5eab098" + integrity sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag== + +"@rollup/rollup-linux-riscv64-musl@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz#655b69144b1e786e0bf8ac3b727f0d7fee590a08" + integrity sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig== + +"@rollup/rollup-linux-s390x-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz#70b175b0361a7898acc294a6b14001e2c3ad99be" + integrity sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g== + +"@rollup/rollup-linux-x64-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz#0c68fa212df249f996b2d75e8fbb3766b1582ee8" + integrity sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw== + +"@rollup/rollup-linux-x64-musl@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz#93af6206edf751469a43c3614164360136540838" + integrity sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w== + +"@rollup/rollup-openbsd-x64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz#7c7680c4004fd602b59cd19f3294fa1b89d0c683" + integrity sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png== + +"@rollup/rollup-openharmony-arm64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz#f389ff4d8ee8a635ff51d76e8ff20b7db2292bce" + integrity sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ== + +"@rollup/rollup-win32-arm64-msvc@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz#55a286f40f9d03c653ceb7183d6c6179ec312c75" + integrity sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A== + +"@rollup/rollup-win32-ia32-msvc@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz#7eb577b64e2a39115182a87a2ea957608d8331f6" + integrity sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ== + +"@rollup/rollup-win32-x64-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz#0e7a7cd5f0db2f570fc312687d67baf21283a3f6" + integrity sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ== + +"@rollup/rollup-win32-x64-msvc@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz#f8e11d8ac827db7695d50f1def4c00a7f3cd1510" + integrity sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ== + +"@types/chai@^5.2.2": + version "5.2.3" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.3.tgz#8e9cd9e1c3581fa6b341a5aed5588eb285be0b4a" + integrity sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA== + dependencies: + "@types/deep-eql" "*" + assertion-error "^2.0.1" + +"@types/deep-eql@*": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== + +"@types/esrecurse@^4.3.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec" + integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw== + +"@types/estree@1.0.9", "@types/estree@^1.0.0", "@types/estree@^1.0.6", "@types/estree@^1.0.8": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" + integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== + +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/node@24.13.3": + version "24.13.3" + resolved "https://registry.yarnpkg.com/@types/node/-/node-24.13.3.tgz#49f18bd3c647866dcda51a0756c145e14590ce16" + integrity sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q== + dependencies: + undici-types "~7.18.0" + +"@typescript-eslint/eslint-plugin@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz#bf74cc392ebcaaf096bc8b4c4d7bbeb0677687b8" + integrity sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA== + dependencies: + "@eslint-community/regexpp" "^4.12.2" + "@typescript-eslint/scope-manager" "8.69.0" + "@typescript-eslint/type-utils" "8.69.0" + "@typescript-eslint/utils" "8.69.0" + "@typescript-eslint/visitor-keys" "8.69.0" + ignore "^7.0.5" + natural-compare "^1.4.0" + ts-api-utils "^2.5.0" + +"@typescript-eslint/parser@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.69.0.tgz#de3ead2b35e5c71580eda40820adb4fd14834ca1" + integrity sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw== + dependencies: + "@typescript-eslint/scope-manager" "8.69.0" + "@typescript-eslint/types" "8.69.0" + "@typescript-eslint/typescript-estree" "8.69.0" + "@typescript-eslint/visitor-keys" "8.69.0" + debug "^4.4.3" + +"@typescript-eslint/project-service@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.69.0.tgz#cf728554436a50e644a5214a89fe02cb1ffa9af8" + integrity sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.69.0" + "@typescript-eslint/types" "^8.69.0" + debug "^4.4.3" + +"@typescript-eslint/scope-manager@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz#13f3d1e25108e95a9ceb5a198806d1fa558f8c7a" + integrity sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ== + dependencies: + "@typescript-eslint/types" "8.69.0" + "@typescript-eslint/visitor-keys" "8.69.0" + +"@typescript-eslint/tsconfig-utils@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz#d3b0ccc781ab252a90a0b3989b9d1eb85ab59469" + integrity sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ== + +"@typescript-eslint/tsconfig-utils@^8.69.0": + version "8.70.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz#e9353a6e5f3498a29b40946ca3e11f6166fef09e" + integrity sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg== + +"@typescript-eslint/type-utils@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz#7ce68d2ebcbedd8421806c27a7f360755017159f" + integrity sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA== + dependencies: + "@typescript-eslint/types" "8.69.0" + "@typescript-eslint/typescript-estree" "8.69.0" + "@typescript-eslint/utils" "8.69.0" + debug "^4.4.3" + ts-api-utils "^2.5.0" + +"@typescript-eslint/types@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.69.0.tgz#5d9ad3f707c2e4f70a2db540031104df3e63bcf5" + integrity sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA== + +"@typescript-eslint/types@^8.69.0": + version "8.70.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.70.1.tgz#2022b9d07d057174eba25b9bbd289641d262a196" + integrity sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q== + +"@typescript-eslint/typescript-estree@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz#efa915913ffe2049bbfd26092b95d1bc7c9c454f" + integrity sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w== + dependencies: + "@typescript-eslint/project-service" "8.69.0" + "@typescript-eslint/tsconfig-utils" "8.69.0" + "@typescript-eslint/types" "8.69.0" + "@typescript-eslint/visitor-keys" "8.69.0" + debug "^4.4.3" + minimatch "^10.2.2" + semver "^7.7.3" + tinyglobby "^0.2.15" + ts-api-utils "^2.5.0" + +"@typescript-eslint/utils@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.69.0.tgz#67ad9c00edf12fe2fbc0bf0a71b00822a8d02e97" + integrity sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw== + dependencies: + "@eslint-community/eslint-utils" "^4.9.1" + "@typescript-eslint/scope-manager" "8.69.0" + "@typescript-eslint/types" "8.69.0" + "@typescript-eslint/typescript-estree" "8.69.0" + +"@typescript-eslint/visitor-keys@8.69.0": + version "8.69.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz#f659785dbb79733c40499f71a65439e2033966b5" + integrity sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w== + dependencies: + "@typescript-eslint/types" "8.69.0" + eslint-visitor-keys "^5.0.0" + +"@vitest/expect@3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.2.7.tgz#70a34158383d008c3bf5d802e2643317f09df6d8" + integrity sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/spy" "3.2.7" + "@vitest/utils" "3.2.7" + chai "^5.2.0" + tinyrainbow "^2.0.0" + +"@vitest/mocker@3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-3.2.7.tgz#331be944cb783c642dd42bd743411aca24ea0466" + integrity sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA== + dependencies: + "@vitest/spy" "3.2.7" + estree-walker "^3.0.3" + magic-string "^0.30.17" + +"@vitest/pretty-format@3.2.7", "@vitest/pretty-format@^3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-3.2.7.tgz#2a7b593f8e007e9d8ef7e7343aa30ec73fdeaf29" + integrity sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA== + dependencies: + tinyrainbow "^2.0.0" + +"@vitest/runner@3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-3.2.7.tgz#c0c080228189f1fa6cda40f59be09d746b0aca51" + integrity sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA== + dependencies: + "@vitest/utils" "3.2.7" + pathe "^2.0.3" + strip-literal "^3.0.0" + +"@vitest/snapshot@3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-3.2.7.tgz#a3a7e1950ce99ec4cf02395e20ddca403b6c818e" + integrity sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g== + dependencies: + "@vitest/pretty-format" "3.2.7" + magic-string "^0.30.17" + pathe "^2.0.3" + +"@vitest/spy@3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-3.2.7.tgz#ca7fbee44019523ca450395d9a2284ce9ece1f31" + integrity sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ== + dependencies: + tinyspy "^4.0.3" + +"@vitest/utils@3.2.7": + version "3.2.7" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-3.2.7.tgz#302c8126211ac4dfea87b3b5085c098d6d22e89e" + integrity sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw== + dependencies: + "@vitest/pretty-format" "3.2.7" + loupe "^3.1.4" + tinyrainbow "^2.0.0" + +acorn-jsx@^5.3.2: + version "5.3.2" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" + integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== + +acorn@^8.16.0: + version "8.18.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.18.0.tgz#4faf01b2d6d326bfeed97aea1f52220b5f4c1940" + integrity sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ== + +ajv@^6.14.0: + version "6.15.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492" + integrity sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== + +balanced-match@^4.0.2: + version "4.0.4" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" + integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== + +bintrees@1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.2.tgz#49f896d6e858a4a499df85c38fb399b9aff840f8" + integrity sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw== + +brace-expansion@^5.0.8: + version "5.0.12" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.12.tgz#995fbb4750a77c4d16a7dc942ff2ca6ef8e675ec" + integrity sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ== + dependencies: + balanced-match "^4.0.2" + +cac@^6.7.14: + version "6.7.14" + resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" + integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== + +chai@^5.2.0: + version "5.3.3" + resolved "https://registry.yarnpkg.com/chai/-/chai-5.3.3.tgz#dd3da955e270916a4bd3f625f4b919996ada7e06" + integrity sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw== + dependencies: + assertion-error "^2.0.1" + check-error "^2.1.1" + deep-eql "^5.0.1" + loupe "^3.1.0" + pathval "^2.0.0" + +check-error@^2.1.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.3.tgz#2427361117b70cca8dc89680ead32b157019caf5" + integrity sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA== + +cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +debug@^4.3.1, debug@^4.3.2, debug@^4.4.1, debug@^4.4.3: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +deep-eql@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" + integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== + +deep-is@^0.1.3: + version "0.1.4" + resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" + integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== + +es-module-lexer@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" + integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== + +"esbuild@^0.27.0 || ^0.28.0": + version "0.28.2" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.2.tgz#0f43bd1bad955b72d24e2261e3abe5957ccf0816" + integrity sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA== + 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" + +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-config-prettier@10.1.8: + version "10.1.8" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz#15734ce4af8c2778cc32f0b01b37b0b5cd1ecb97" + integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w== + +eslint-scope@^9.1.2: + version "9.1.2" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.2.tgz#b9de6ace2fab1cff24d2e58d85b74c8fcea39802" + integrity sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ== + dependencies: + "@types/esrecurse" "^4.3.1" + "@types/estree" "^1.0.8" + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-visitor-keys@^3.4.3: + version "3.4.3" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" + integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== + +eslint-visitor-keys@^5.0.0, eslint-visitor-keys@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" + integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== + +eslint@10.9.0: + version "10.9.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.9.0.tgz#3d86068a06c6c78161a4062e69874d38f59d498b" + integrity sha512-5KeEOJZBfEVA47boFiBsf+6MmmJpffM7qEBg4pLla2e4nlKgdKlqCW0oSLOGsT8Wl5uCGJptLV1bkaiShj90Gw== + dependencies: + "@eslint-community/eslint-utils" "^4.8.0" + "@eslint-community/regexpp" "^4.12.2" + "@eslint/config-array" "^0.23.5" + "@eslint/config-helpers" "^0.7.0" + "@eslint/core" "^1.2.1" + "@eslint/plugin-kit" "^0.7.2" + "@humanfs/node" "^0.16.6" + "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" + ajv "^6.14.0" + cross-spawn "^7.0.6" + debug "^4.3.2" + escape-string-regexp "^4.0.0" + eslint-scope "^9.1.2" + eslint-visitor-keys "^5.0.1" + espree "^11.2.0" + esquery "^1.7.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^8.0.0" + find-up "^5.0.0" + glob-parent "^6.0.2" + ignore "^5.2.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + json-stable-stringify-without-jsonify "^1.0.1" + minimatch "^10.2.5" + natural-compare "^1.4.0" + optionator "^0.9.3" + +espree@^11.2.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5" + integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw== + dependencies: + acorn "^8.16.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^5.0.1" + +esquery@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== + dependencies: + estraverse "^5.1.0" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^5.1.0, estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + +esutils@^2.0.2: + version "2.0.3" + resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" + integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== + +expect-type@^1.2.1: + version "1.4.0" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.4.0.tgz#24edf7f0cc69a44d008567ba4594ab96f3c3a3d6" + integrity sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA== + +fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-levenshtein@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" + integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== + +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== + dependencies: + flat-cache "^4.0.0" + +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== + dependencies: + flatted "^3.2.9" + keyv "^4.5.4" + +flatted@^3.2.9: + version "3.4.4" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.4.tgz#aeeca2a506303f0cee61c59e6c9f2a88d2f29fc6" + integrity sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q== + +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +glob-parent@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" + integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== + dependencies: + is-glob "^4.0.3" + +ignore@^5.2.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" + integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== + +ignore@^7.0.5: + version "7.0.9" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.9.tgz#475b2197ade916edab05ade35c691518e8543382" + integrity sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw== + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-glob@^4.0.0, is-glob@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +js-tokens@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.1.tgz#2ec43964658435296f6761b34e10671c2d9527f4" + integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== + +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== + +keyv@^4.5.4: + version "4.5.4" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" + integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== + dependencies: + json-buffer "3.0.1" + +levn@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" + integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ== + dependencies: + prelude-ls "^1.2.1" + type-check "~0.4.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +loupe@^3.1.0, loupe@^3.1.4: + version "3.2.1" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.2.1.tgz#0095cf56dc5b7a9a7c08ff5b1a8796ec8ad17e76" + integrity sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ== + +magic-string@^0.30.17: + version "0.30.21" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.5" + +minimatch@^10.2.2, minimatch@^10.2.4, minimatch@^10.2.5: + version "10.2.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef" + integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A== + dependencies: + brace-expansion "^5.0.8" + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nanoid@^3.3.18: + version "3.3.19" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.19.tgz#336d4aa4bcd4fb24d2cddede7ffeae40bec03f0a" + integrity sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +optionator@^0.9.3: + version "0.9.4" + resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" + integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== + dependencies: + deep-is "^0.1.3" + fast-levenshtein "^2.0.6" + levn "^0.4.1" + prelude-ls "^1.2.1" + type-check "^0.4.0" + word-wrap "^1.2.5" + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== + +pathval@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d" + integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +picomatch@^4.0.2, picomatch@^4.0.3, picomatch@^4.0.4: + version "4.0.7" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.7.tgz#6313360034ccb36b3dc61ecbdff78121f90fe21f" + integrity sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA== + +playwright-core@1.62.1: + version "1.62.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.62.1.tgz#120f67a19181bfd183c60fa903c0d99330b56785" + integrity sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw== + +playwright@1.62.1: + version "1.62.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.62.1.tgz#8447b6755e8aec85a3cb7207c823e3ed2fc66700" + integrity sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg== + dependencies: + playwright-core "1.62.1" + optionalDependencies: + fsevents "2.3.2" + +postcss@^8.5.6: + version "8.5.28" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.28.tgz#da4563a99a06e62d6c1cd1acae363224bcaed6e9" + integrity sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A== + dependencies: + nanoid "^3.3.18" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +prelude-ls@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" + integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== + +prom-client@15.1.3: + version "15.1.3" + resolved "https://registry.yarnpkg.com/prom-client/-/prom-client-15.1.3.tgz#69fa8de93a88bc9783173db5f758dc1c69fa8fc2" + integrity sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g== + dependencies: + "@opentelemetry/api" "^1.4.0" + tdigest "^0.1.1" + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +rollup@^4.43.0: + version "4.63.4" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.63.4.tgz#cff29b8d6d959aee7cb5e44b0dad70074493dbf8" + integrity sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ== + dependencies: + "@types/estree" "1.0.9" + optionalDependencies: + "@napi-rs/lzma-linux-x64-gnu" "1.5.1" + "@rollup/rollup-android-arm-eabi" "4.63.4" + "@rollup/rollup-android-arm64" "4.63.4" + "@rollup/rollup-darwin-arm64" "4.63.4" + "@rollup/rollup-darwin-x64" "4.63.4" + "@rollup/rollup-freebsd-arm64" "4.63.4" + "@rollup/rollup-freebsd-x64" "4.63.4" + "@rollup/rollup-linux-arm-gnueabihf" "4.63.4" + "@rollup/rollup-linux-arm-musleabihf" "4.63.4" + "@rollup/rollup-linux-arm64-gnu" "4.63.4" + "@rollup/rollup-linux-arm64-musl" "4.63.4" + "@rollup/rollup-linux-loong64-gnu" "4.63.4" + "@rollup/rollup-linux-loong64-musl" "4.63.4" + "@rollup/rollup-linux-ppc64-gnu" "4.63.4" + "@rollup/rollup-linux-ppc64-musl" "4.63.4" + "@rollup/rollup-linux-riscv64-gnu" "4.63.4" + "@rollup/rollup-linux-riscv64-musl" "4.63.4" + "@rollup/rollup-linux-s390x-gnu" "4.63.4" + "@rollup/rollup-linux-x64-gnu" "4.63.4" + "@rollup/rollup-linux-x64-musl" "4.63.4" + "@rollup/rollup-openbsd-x64" "4.63.4" + "@rollup/rollup-openharmony-arm64" "4.63.4" + "@rollup/rollup-win32-arm64-msvc" "4.63.4" + "@rollup/rollup-win32-ia32-msvc" "4.63.4" + "@rollup/rollup-win32-x64-gnu" "4.63.4" + "@rollup/rollup-win32-x64-msvc" "4.63.4" + fsevents "~2.3.2" + +semver@^7.7.3: + version "7.8.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" + integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +siginfo@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" + integrity sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g== + +source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +stackback@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" + integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw== + +std-env@^3.9.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b" + integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== + +strip-literal@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-3.1.0.tgz#222b243dd2d49c0bcd0de8906adbd84177196032" + integrity sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg== + dependencies: + js-tokens "^9.0.1" + +tdigest@^0.1.1: + version "0.1.3" + resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.3.tgz#831a1fd531f207127ab75d054d4c6c6dedab3ac7" + integrity sha512-zbRt+lT+/H4fRItHshczHErVCQnitJk8MfMT24MqFJf3YL7SJJPqGIGeuOdvxXxM/AHFzKBl7WoyaYwqO9s3Kw== + dependencies: + bintrees "1.0.2" + +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" + integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== + +tinyglobby@^0.2.14, tinyglobby@^0.2.15: + version "0.2.17" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.17.tgz#562a9a6c9eb2b3b123d39719f9af5bb44fcd7631" + integrity sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.4" + +tinypool@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.1.1.tgz#059f2d042bd37567fbc017d3d426bdd2a2612591" + integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg== + +tinyrainbow@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz#9509b2162436315e80e3eee0fcce4474d2444294" + integrity sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw== + +tinyspy@^4.0.3: + version "4.0.6" + resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-4.0.6.tgz#68e278e20287f37beb5f4b0a4db211d280400562" + integrity sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg== + +ts-api-utils@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz#4acd4a155e22734990a5ed1fe9e97f113bcb37c1" + integrity sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA== + +type-check@^0.4.0, type-check@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" + integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew== + dependencies: + prelude-ls "^1.2.1" + +typescript-eslint@8.69.0: + version "8.69.0" + resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.69.0.tgz#28a83f296d9c14001ea0691750c90cac8e94ba96" + integrity sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA== + dependencies: + "@typescript-eslint/eslint-plugin" "8.69.0" + "@typescript-eslint/parser" "8.69.0" + "@typescript-eslint/typescript-estree" "8.69.0" + "@typescript-eslint/utils" "8.69.0" + +typescript@6.0.3: + version "6.0.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21" + integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== + +undici-types@~7.18.0: + version "7.18.2" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.18.2.tgz#29357a89e7b7ca4aef3bf0fd3fd0cd73884229e9" + integrity sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w== + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +vite-node@3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.4.tgz#f3676d94c4af1e76898c162c92728bca65f7bb07" + integrity sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg== + dependencies: + cac "^6.7.14" + debug "^4.4.1" + es-module-lexer "^1.7.0" + pathe "^2.0.3" + vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" + +"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0": + version "7.3.6" + resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.6.tgz#0547a395e68d3746e9a505f1fd4469fe09b49cc4" + integrity sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg== + dependencies: + esbuild "^0.27.0 || ^0.28.0" + fdir "^6.5.0" + picomatch "^4.0.3" + postcss "^8.5.6" + rollup "^4.43.0" + tinyglobby "^0.2.15" + optionalDependencies: + fsevents "~2.3.3" + +vitest@3.2.7: + version "3.2.7" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-3.2.7.tgz#1944b6ed013a25fd26a73d18e1af92c10a57af6c" + integrity sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/expect" "3.2.7" + "@vitest/mocker" "3.2.7" + "@vitest/pretty-format" "^3.2.7" + "@vitest/runner" "3.2.7" + "@vitest/snapshot" "3.2.7" + "@vitest/spy" "3.2.7" + "@vitest/utils" "3.2.7" + chai "^5.2.0" + debug "^4.4.1" + expect-type "^1.2.1" + magic-string "^0.30.17" + pathe "^2.0.3" + picomatch "^4.0.2" + std-env "^3.9.0" + tinybench "^2.9.0" + tinyexec "^0.3.2" + tinyglobby "^0.2.14" + tinypool "^1.1.1" + tinyrainbow "^2.0.0" + vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" + vite-node "3.2.4" + why-is-node-running "^2.3.0" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== + dependencies: + siginfo "^2.0.0" + stackback "0.0.2" + +word-wrap@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" + integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==