diff --git a/.github/workflows/cps-shared-ui-checkers.yml b/.github/workflows/cps-shared-ui-checkers.yml index 2a639d322..2aa3ba9d4 100644 --- a/.github/workflows/cps-shared-ui-checkers.yml +++ b/.github/workflows/cps-shared-ui-checkers.yml @@ -27,6 +27,12 @@ jobs: - name: Build app run: npm run build + - name: Build telemetry library + run: npm run build:telemetry + + - name: Build composition + run: npm run build:documentation + test: runs-on: ubuntu-latest steps: @@ -48,6 +54,9 @@ jobs: - name: Run library unit tests with coverage run: npm run test:coverage:cps-ui-kit + - name: Run telemetry library unit tests + run: npm run test:cps-telemetry + - name: Comment coverage on PR uses: ArtiomTr/jest-coverage-report-action@v2 if: github.event_name == 'pull_request' diff --git a/.gitignore b/.gitignore index a540f63d8..e52d5b86b 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,10 @@ yarn-error.log testem.log /typings +# Regenerated by `npm run trace:telemetry` — see +# projects/composition/src/app/telemetry-trace.capture.spec.ts +/projects/composition/telemetry-trace + # System files .DS_Store Thumbs.db diff --git a/CHANGES_DESC.md b/CHANGES_DESC.md new file mode 100644 index 000000000..0e481a310 --- /dev/null +++ b/CHANGES_DESC.md @@ -0,0 +1,642 @@ +# Add `cps-telemetry`: a reusable Angular telemetry library, wired into `composition` + +## Summary + +Adds `cps-telemetry`, a new Angular library providing application logging, +scenario (user-journey) health telemetry, business/UX event tracking, and +PII redaction, with pluggable transport sinks — AWS CloudWatch RUM, +cross-realm broadcast (for micro-frontend compositions), and a no-op sink +for local development. Nothing in the library is specific to any one +application: event names, configuration, and AWS credentials are all +supplied by the host app. + +The library has a single real runtime dependency, `tslib`. `aws-rum-web` +is an optional peer dependency, needed only when the RUM sink is used, so +an application using only the broadcast or no-op sink never pulls in the +AWS SDK. This is enforced structurally, not just by convention: the RUM +sink and its providers live in a separate secondary entry point, +`cps-telemetry/rum` (see "Two entry points" under Sinks below), so an +application that never imports from it never needs `aws-rum-web` +resolvable at build time either. + +This PR also wires the library end-to-end into `composition`, the +component-library documentation/demo app, so every major capability +(scenarios, logs, BI events, redaction) is exercised by real, running +code rather than only by unit tests. + +Architecture, design rationale, and a line-by-line verification of every +AWS RUM SDK capability claim (checked against the installed +`aws-rum-web@3.2.1` source, not just its docs) live in +`projects/cps-telemetry/DESIGN.md`. Usage documentation lives in +`projects/cps-telemetry/README.md`. + +--- + +## Scenario telemetry + +`CpsScenario` / `CpsScenarioTelemetryService` model one user journey — load +customer data, submit a form, run a search — as an object with steps, +aggregates, and a terminal outcome. + +- **Five terminal statuses**: `success`, `failure`, `abandoned`, + `incomplete`, `timeout`. There is no "in progress" status — a running + scenario simply has none yet (`scenario.status` is `undefined` until + settlement); `scenario.isSettled` answers whether it's done. Settling is + absorbing: a second `complete()`/`fail()`/etc. after the first is a + documented no-op, not a throw. +- **Automatic step management**: opening a step closes the previous one as + completed; steps carry redacted metadata, an optional message, and a + normalized error on failure. `steps[]` always has at least two entries + (synthetic `scenario-start`/`scenario-end` boundary markers), even for a + scenario that never calls `.step()`. +- **Aggregates** (`aggregateStart`/`aggregateEnd`) sum a repeatedly-invoked + operation (a formatter run per row, a validator per field) into one total + instead of one step per call, so a hot loop doesn't blow through the + step budget or the record's own size. +- **Per-scenario timeout** (default 30s, configurable per call or globally, + `0` disables): a scenario that never settles auto-settles as `timeout` + rather than silently disappearing from the data. +- **Page-unload safety**: every in-flight scenario is settled as + `abandoned` (`page-hidden`) on `pagehide`, with the sink flushed via + `sendBeacon`. Concurrent scenarios (a dashboard loading three panels at + once) are independent — there is no ambient "current scenario." +- **User Timings integration**: optional `performance.mark`/`measure` + entries on the DevTools Performance track, gated by config or a + LocalStorage debug flag. Marks are cleared at settle; a scenario with its + timeout explicitly disabled and no navigation to clean it up is still + guarded by an independent mark-cleanup fallback, so marks can't + accumulate in the Performance buffer for the life of the page. +- **`elapsed` (the scenario and step timeline-position field) is built from + the _host_ page's clock, not the local realm's.** It exists specifically + so events from one session can be lined up against each other — but a + fragment's own `performance.now()` runs from a later `timeOrigin` than + the shell's, so a per-realm value would silently put shell- and + fragment-originated records on two different, non-comparable timelines + despite sharing one session id. `cpsElapsedNow()` prefers + `top.performance.now()`, mirroring the same fix already applied to User + Timing marks above, falling back to the local realm's clock if `top` + throws (cross-origin, sandboxed). +- **`maxKeys` is enforced on the combined metadata, not each bag in + isolation.** `setData()`, an outcome's metadata at settle, and a step's + initial plus closing metadata are all merged through a shared helper + that re-applies the cap to the merged result — a key already present is + always updatable, but a genuinely new key is dropped once the combined + count reaches `maxKeys`. Capping each bag independently before merging + (the previous behavior) let the total grow past the configured bound. +- **`toRecord()`** is public and safe to call mid-flight — its `status` + field is honestly typed as absent until the scenario has actually + settled, and its returned `steps` and `metadata` are deep-cloned, not + just a fresh array shell around shared objects, so mutating a mid-flight + snapshot (including a nested step's own `metadata`) can never change + what's later emitted at settlement. +- **`complete()`/`fail()`/`incomplete()`/`cancel()` share one signature** — + `(outcome?: CpsScenarioOutcome)` — so custom metadata, status codes, a + `reason`/`message`, and (for `fail()`) the thrown `error` are all + available identically regardless of which one is called. +- **`traceScenario()`** RxJS pipeable operator bridges Observable streams + directly to scenario completion/failure with optional outcome derivation, + with the outcome mapper itself guarded so a throwing mapper still settles + the scenario (as a success with no outcome) instead of crashing the + subscription. A teardown that isn't a `complete`/`error` — a superseding + `switchMap`, `takeUntilDestroyed()`, a manual unsubscribe — cancels the + scenario instead of leaving it to self-settle as a `timeout`; guarded by + `isSettled` so it never fires again after a real settle. That + cancellation carries no `reason` unless `cancelOutcome` is supplied — a + caller's own `scenario.cancel({ reason })` from a `switchMap` projector + is always too late, since `switchMap` unsubscribes (and thereby settles) + the prior inner Observable before the new projector ever runs. +- **`CpsScenarioTelemetryService.settled$`** is a public `Observable` of + every scenario as it settles, plus `.find(scenarioId)` and + `.getActive()` for introspecting in-flight scenarios — a debug overlay, + a retry prompt, or a test harness can all build on this directly. Each + emission is an independent deep copy, not the same object handed to the + sink, so a subscriber mutating it can never change what was already (or + is about to be) shipped; the copy is skipped entirely when nothing is + subscribed, so the common unobserved case pays nothing for it. + +## Logging + +`CpsLoggerService` provides structured, leveled application logs, decoupled +from any specific backend. + +- **No hard dependency on a sink**: logging's actual destination is the + application's `CpsLogApiProvider`, not `CpsTelemetrySink` — a sink is + only an optional enrichment source (session/user id correlation, + RUM error mirroring). An application that wants only structured logging, + with no scenarios, BI events, or RUM at all, is not required to configure + any sink to use it, unlike `CpsScenarioTelemetryService`/ + `CpsBiTelemetryService`, which still fail at bootstrap without one. +- **Per-logger minimum levels**, overridable per named logger, so one noisy + area can run verbose while the rest of the app stays quiet. +- **Delivery is entirely the application's `CpsLogApiProvider` policy**: the + library does no batching of its own — `send()` is called once per record, + as it is written. A provider that wants to batch, retry, or authenticate + does so on its own terms, with full knowledge of what its endpoint + accepts. +- **Flush reliability at the end of a session**: a provider's optional + `flush()` is called on `pagehide`, on `visibilitychange` going hidden + (the case mobile browsers routinely produce by killing a backgrounded tab + without ever firing `pagehide`), and on teardown — so a provider that + chooses to queue its own records doesn't lose the last, still-pending + ones when the tab closes. +- **Correlation**: passing a scenario's `logger` binds its id as + `correlationId` on every line that logger writes, so frontend logs, + frontend telemetry, and backend logs can be reassembled into one journey. + `correlationId` is scrubbed the same way every other string field on a + log record is — normally a plain scenario uuid, unaffected either way, + but nothing enforces that at runtime, so a caller accidentally binding + something sensitive there no longer bypasses redaction on its way to the + application's own log backend. +- **`debugLogger` LocalStorage flag** mirrors records to the console + (optionally scoped to specific logger names), independent of production + configuration, so a deployed build can be inspected from DevTools without + a redeploy. Console lines from all three concerns are prefixed + `[][]` rather than with this library's own name — + the same application-first rule already used for User Timing entry names, + so that in a composed page the realm a line came from is visible at a + glance instead of every realm sharing one indistinguishable prefix. +- **`CPS_LOG_LEVEL_ORDER`** is exported, so a consumer's own log-query + filtering can compare levels without re-declaring the ordering. + +## Business/UX events + +`CpsBiTelemetryService.track()` records discrete, durationless events — +feature adoption, interaction analysis, funnel steps — supplied entirely by +the application (the library defines no business vocabulary of its own). + +- **Double-fire absorption**: an identical event — same name, scenario + correlation, event-type override, feature, and metadata content — within + a 400ms window is collapsed, covering the common case of a handler bound + to both `click` and `keydown`. The dedup key includes metadata content + specifically so two different-but-same-named events in quick succession + (two different theme options picked back to back) are never mistaken for + a double-fire of one, and includes `eventType`/`feature` so two events + that only differ in those fields are never dropped as duplicates of each + other — the dedup key is built with `JSON.stringify`, not delimiter + joining, so e.g. `eventType: 'x|y', feature: 'z'` can no longer collide + with `eventType: 'x', feature: 'y|z'` the way naive `${a}|${b}` joining + would. The key cap's oldest-entry eviction now actually evicts the oldest + entry: a key re-emitted after its own dedup window elapses is deleted and + reinserted, not merely updated in place, so it moves to the front of the + `Map`'s iteration order the way its refreshed timestamp implies — without + this, a just-refreshed key could still look "oldest" by insertion order + and get evicted at capacity, letting an immediate duplicate straight + through right after. A duplicate hit refreshes the same position without + extending the window — otherwise a frequently-duplicated ("hot") key, + the only kind that never reaches that refresh, would stay pinned at + whichever position it held from its very first insertion and become the + prime eviction candidate precisely because it's actively in use. +- **Monotonic timing**: the dedup window is measured against + `performance.now()`, not the wall clock, so it can't be wedged by a + backward time jump (an NTP correction, a device waking from sleep). +- **The dedup key is built from redacted metadata**, not the raw values + passed to `track()` — `metadata` and `feature` are both scrubbed once, + up front, and the redacted result is reused for the key and the emitted + event alike. Building the key from raw values first would leave + sensitive content sitting as a `Map` key in this root service's memory + for the page lifetime, even though the emitted event was correctly + redacted all along. + +## PII redaction + +`cps-telemetry-redact.util.ts` provides the shallow, synchronous redaction +pass every metadata object, log message, and normalized error goes through +before reaching a sink. + +- **Key-name denylist**, extensible per-application via + `extraKeyPatterns`, catches conventionally-named sensitive fields + (`password`, `token`, `secret`, `ssn`, …) regardless of value shape. +- **Opt-in value-content scanning** (`scanValuePatterns`) additionally + redacts email addresses, credit-card numbers (Luhn-validated to avoid + false positives on unrelated digit runs), SSNs, IPv4 addresses, and phone + numbers found _inside_ a string value, not just under a suspicious key + name — closing the gap where PII lands under an innocuous key. Off by + default; each entity type is opted into individually. The `'phone'` + pattern covers NANP (US/Canada) numbers plus international-format numbers + from South Africa, Botswana, Ghana, + Kenya, Mauritius, Mozambique, Namibia, Seychelles, Tanzania, Uganda, + Zambia. +- **URL scrubbing** strips query strings and fragments wherever a URL + appears in a string, including one embedded inside a longer message, not + only when the whole string is a bare path. +- **Error normalization** (`cpsNormalizeError`) redacts stack traces and + messages, and specifically recognizes `HttpErrorResponse`-shaped objects + (structurally, without importing `@angular/common/http`) so an HTTP + failure's name/message survive normalization instead of being flattened + to a generic, unhelpful `Error`. +- **`extraValueTransforms`** — application-supplied `(value: string) => +string` functions run on every string value after all pattern-based + scrubbing, an escape hatch for redaction logic no regex can express. A + throwing transform is skipped (logged in dev mode only), not fatal, and + never blocks the rest of the pipeline. +- **Per-concern redaction toggle**: `withLogging`, `withScenarios`, and + `withBiEvents` each accept a `redact: boolean` (default `true`). Turning + it off for one concern disables only the _configurable_ PII scrubbing + (`extraKeyPatterns`, value-pattern scanning, URL-query stripping) for + that concern — the built-in credential denylist, size caps, error + normalization, and `extraValueTransforms` are a safety floor and stay on + regardless, via `cpsRedactConfigFor()`. +- **Per-resolved-config isolation**: `extraKeyPatterns`/`extraValuePatterns`/`extraValueTransforms` + arrays are always copied fresh per `provideCpsTelemetry()` call, so + mutating one application's array can never leak into another's. + +## Sinks + +`CpsTelemetrySink` is the six-method abstraction every sink implements; +applications inject the same services regardless of which is active. + +- **RUM sink** (`CpsRumTelemetrySink`) wraps `aws-rum-web`, lazy-loaded so + the SDK is never in an application's bundle unless the RUM sink is + actually selected. + - **Two entry points.** `CpsRumTelemetrySink`, `provideCpsTelemetryRumSink`, + and everything `CpsRumCredentialsProvider`-shaped are exported from a + separate secondary entry point, `cps-telemetry/rum`, not the main + `cps-telemetry` barrel. Lazy-loading `aws-rum-web` at runtime prevents + it from being _bundled_ unless reached, but does not prevent it from + being _resolved_ — a static `import` is parsed as part of building the + module graph before any dead-code elimination runs, and a bundler must + resolve a dynamic `import()`'s specifier at build time to construct its + lazy chunk regardless of whether that branch ever executes. So keeping + `CpsRumTelemetrySink` out of the main entry point's module graph + entirely — not just tree-shaking it — is what lets an application + using only `provideCpsTelemetrySink('broadcast' | 'noop')` skip + installing `aws-rum-web` altogether. The cost: ng-packagr hardcodes + each entry point's TypeScript `rootDir` to that entry's own directory, + so `cps-telemetry/rum` cannot reach the main entry's internal + `cpsSafe`/`cpsIsBrowser`/`cpsUuid`-style helpers by relative import; + rather than exporting them publicly just to satisfy that constraint, it + carries a small verbatim private copy of its own. See DESIGN.md §3, + "Entry points", for the full reasoning. + - Exposes the library's near-complete `aws-rum-web` configuration + surface (sampling, session behavior, dispatch/buffering, cookies, + page tracking, tracing) through `CpsRumAppMonitorConfig`, hand-typed so + no SDK types leak into the public API; every field with a real SDK + default is left unset unless the application overrides it, so the + library's defaults never drift from the SDK's own. + - Credentials come from an application-supplied + `CpsRumCredentialsProvider`; a failed or unreachable broker degrades to + a disabled-but-non-throwing sink rather than breaking the app. + - Credential refresh retries on failure instead of the refresh chain + dying permanently after one transient error, and is safe against the + destroy/async race where the sink is torn down while a refresh (or the + initial client construction) is still in flight. A refresh due to fire + immediately — already-expired or newly-issued credentials expiring + within the refresh skew window — falls back to the same bounded retry + delay instead of scheduling an immediate refresh, so a broker stuck + returning bad credentials can't tight-loop the sink. + - A credential refresh returning `null` disables RUM for the session, as + documented on `CpsRumCredentialsProvider.load()`: the client is torn + down — the SDK's own `disable()` is called on the live instance, not + just the reference dropped, so its listeners, plugins, and dispatch + timer actually stop — rather than continuing to collect with now-stale + credentials, and events recorded after that point are dropped rather + than silently queued forever in the pre-init buffer. The same teardown + runs on `ngOnDestroy()`, so an Angular-destroyed sink can't leave an + orphaned client still collecting (and, if the app is bootstrapped + again in the same page, duplicating telemetry). + The very first load honors the identical contract, not only a later + refresh — declining before the client is ever constructed, or the SDK + throwing anywhere in the init sequence (construction, or applying + identity/credentials to an already-constructed client), now also + disables the session and discards the pre-init buffer, instead of + leaving every subsequent `record()` call buffering into the capped + pre-init queue forever, a stale `flush()` warning about init being + unfinished when it had actually just been declined, or — for a throw + after construction — an orphaned, still-running client nothing ever + tore down. + - A bounded pre-init buffer means telemetry recorded before the RUM + client finishes initializing is not silently lost, and a dev-mode + warning fires specifically when the page unloads before that + resolution — not when the buffer is instead cleared because RUM was + declined or failed to initialize, which is a deliberate silent + discard, not an unload-mid-init loss. `record()`, + `recordPageView(pageId)`, and `recordError(error)` all buffer and + replay identically — an error mirrored from `CpsLoggerService` (or + reported directly) before the SDK loads is preserved, not dropped. + The three record/buffer/dispatch call sites share one internal + helper, so the guard can't drift between them. + - `CpsRumAppMonitorConfig.clientBuilder` is typed on `aws-rum-web`'s own + `ClientBuilder` parameter shape in full (`endpoint: URL, region: +string, credentials?: ClientBuilderCredentials, compressionStrategy?: +{ enabled: boolean }`) rather than `(...args: unknown[]) => unknown` — + the latter looks more + permissive but is actually the opposite: TypeScript checks function + parameters contravariantly, so a real, concretely-typed `ClientBuilder` + is not assignable to a parameter typed `unknown`, and the escape hatch + could never be used without a cast. +- **Broadcast sink/host** (`CpsBroadcastTelemetrySink` / + `CpsTelemetryBroadcastHost`) let a micro-frontend fragment forward its + telemetry to the shell that owns the real RUM client, so a composed page + gets one AWS session and one event budget instead of one per fragment. + Session id **and** user id are synced two-way across every realm on the + channel — a user id set in one fragment is visible to a sibling + fragment's own logs, not just to the shell. + - The shell can safely run in more than one tab without double-recording + one event: `CpsTelemetryBroadcastHost` holds a Web Locks-based leader + election per channel, so only the elected tab records forwarded + telemetry. "Passive" describes the losing tab's _host_ only, not its + fragments — they keep forwarding on the same origin-wide channel + regardless, so their events are recorded (once, not lost) through the + _winning_ tab's AWS RUM client, attributed to that tab's session and + page rather than their own. If the same composed page could + realistically be open in more than one tab, give each tab's own + instance a unique channel name to avoid this — see README.md + "Multiple tabs of the same composed page" and DESIGN.md §13 "Known + limits". Fails open (elects + immediately) when the Locks API is unavailable, or if a lock request + itself is rejected — or throws synchronously instead of rejecting, + guarded the same way now that this runs unguarded inside an + `APP_INITIALIZER`, where an uncaught throw would otherwise crash + application bootstrap rather than just fail to elect a leader. A tab + destroyed while its own election is still + queued (never granted) no longer deadlocks the channel — it is + recognized as released the moment it is granted, and the lock passes + straight to the next queued tab instead of being held forever by one + that already closed. + - A genuine duplicate host provider (e.g. `provideCpsTelemetryBroadcastHost()` + supplied in both a root and a lazy-loaded module) is detected and warned + about via a small same-realm registry, not the `identity` broadcast — + the broadcast is origin-wide and can't tell a real duplicate apart from + a different, legitimate tab, which must never warn. + - Every message kind on the wire protocol is now field-validated, not + just its `kind` discriminant — `identity`'s `sessionId`/`userId` and a + forwarded error's `stack` are all type-checked before being trusted. + An `event`/`error` message's optional `metadata` is validated too, not + just passed through: any same-origin code can post to a + `BroadcastChannel` by name, and without this a payload with a nested + object/array value would reach a receiving sink's own sanitizer (e.g. + `CpsRumTelemetrySink.sanitize()`, which trusts the shape rather than + re-checking it) unvalidated. + - A forwarded error carries the originating fragment's identity through + the wire protocol; since the underlying AWS RUM client's `recordError` + has no metadata parameter to carry it, the RUM sink folds a + cross-realm origin into the error's `name` (e.g. `[fragment-app] +TypeError`) rather than losing it and attributing every fragment error + to the shell. +- **No-op sink** for local development and testing — telemetry calls + succeed and do nothing, so a fragment developed standalone never breaks + for lack of a shell to compose into. Every override keeps the abstract + `CpsTelemetrySink` method's full parameter list rather than narrowing to + zero arguments — TypeScript's bivariant method-override checking would + have allowed the narrower form to still satisfy `extends +CpsTelemetrySink`, but it would make the _concrete_ `CpsNoopTelemetrySink` + type itself reject a normal call like `.record(type, payload)`. + `getUserId()` retains what `setUserId()` was last given, rather than + always reporting `undefined` — the one piece of state this sink can't + just discard along with everything else, since `CpsLoggerService` reads + it for user correlation on every log record, and logs still reach a real + `CPS_LOG_API_PROVIDER` even in noop mode. + +## Configuration & providers + +- `provideCpsTelemetry(identity, ...features)` takes the application's + identity (`application`/`environment`/`version`, event namespace) as a + mandatory first argument, composed with optional, independently + omittable `withLogging(...)`, `withScenarios(...)`, `withBiEvents(...)`, + `withRedaction(...)` features — mirroring Angular's own + `provideHttpClient(withInterceptors(...))` convention. Each concern gets + its own DI token (`CPS_LOG_CONFIG`, `CPS_SCENARIO_TELEMETRY_CONFIG`, + `CPS_BI_TELEMETRY_CONFIG`, `CPS_REDACT_CONFIG`, plus `CPS_TELEMETRY_IDENTITY` for + identity), so a consumer can override one concern through plain DI + substitution without touching the others. BI event tracking (dedup + window and key cap) is configurable via `withBiEvents(...)` rather than + fixed at hardcoded constants. +- `provideCpsTelemetrySink('broadcast' | 'noop')`, + `provideCpsTelemetryRumSink()` (from `cps-telemetry/rum`), and + `provideCpsTelemetryBroadcastHost()` select the transport explicitly — + there is no default destination, so an app can never look wired up while + silently shipping nothing. +- Scenario, step, logger, and BI event names are typed as a closed + vocabulary via TypeScript module augmentation + (`declare module 'cps-telemetry'`), so a typo in a name is a compile error + instead of a second, silently incomplete metric series. + +--- + +## `composition` integration + +Wires the library into real, running pages rather than only into unit +tests. `AppTelemetryService` (new) owns route-navigation scenario tracking +and application-level BI/log calls, and logs `'Application started'` +(with the browser's `navigator.language`, when running in one — `start()` +is called unconditionally from `AppComponent`, before its own +`isPlatformBrowser` check, so this is `isPlatformBrowser`-gated internally +too, to stay safe if `composition` ever gains an SSR target) once +tracking begins to bound the session in the log stream; the page-level +integrations below build on it: + +- **Route navigation** — every router navigation is a scenario + (`resolve-route` → `activate`), giving concurrent/superseded navigations + real coverage. A sidebar nav-link click is recorded via + `markNavigationIntent()` and backdates the scenario's start to the click + itself rather than the router's own (slightly later) `NavigationStart`, + so event-handling/guard/change-detection time is counted as part of the + wait the user actually experienced. A route landed on without its tab + segment (e.g. bare `/checkbox`) is redirected to `examples` by a + `CanMatch` guard (`defaultTabRedirectGuard`); since any guard-returned + redirect restarts navigation under a new id, `AppTelemetryService` + recognizes that specific restart (`NavigationCancel`'s + `code === NavigationCancellationCode.Redirect`) and continues the same + scenario into the follow-up navigation instead of opening a second one — + one user-visible navigation still produces exactly one scenario record. + `NavigationSkipped` (a terminal event, notably for same-URL navigation) + is handled too: it clears any pending click-intent and settles any + scenario already opened for that navigation id, so a skipped navigation + can't leave a stale intent around to wrongly backdate the _next_, + unrelated navigation within the 2s freshness window. +- **File upload** — processing scenarios are tracked per upload widget + (keyed by filename), so two `` widgets sharing the same + processing callback can be interacted with independently without one's + cancel affecting the other's in-flight scenario; a scenario still + processing when the page navigates away is cancelled, not leaked. Every + widget on the page — including the one demonstrating a failing callback + — wires `(fileProcessingCancelled)` to cancel its own scenario; without + it, clicking that widget's cancel button unsubscribes the processing + pipeline before the scenario ever learns the user cancelled, leaving it + to self-settle as a `timeout` 30s later instead of an immediate, + accurate cancellation. A successful upload settles its scenario directly + in the processing pipeline's own `next`/`error` handlers rather than via + `traceScenario`'s stream-completion hook: `CpsFileUploadComponent` wraps + the callback's observable in `take(1)`, which unsubscribes right after + the first value — before the source's own `complete` notification would + reach a downstream `tap()` — so a successful upload would otherwise never + call `scenario.complete()` and would sit active until its own timeout. +- **Autocomplete** — search scenarios per widget (single/multi), with + supersede-on-new-query cancellation; a search or validation error is + caught, settles the scenario as failed, and leaves the search pipeline + able to serve future queries rather than dying after the first failure. + Selecting an option to validate follows the same supersede pattern: a + newer selection cancels a still-running validation via `switchMap` + rather than the two running independently and clobbering each other's + state, and the validating indicator is reliably cleared on both the + success and error paths. The `reason: 'superseded'` on that cancellation + is threaded through `traceScenario`'s `cancelOutcome`, not a direct + `scenario.cancel(...)` call — the latter would be a no-op here, since + `switchMap` already settles the prior scenario before a manual cancel in + the new projector could run. +- **Table lazy-load** — server-side page fetches are tracked as scenarios, + cancelling a superseded in-flight load. A destroy landing between the + load's queued microtask and the timer it schedules is guarded against + explicitly, so a page navigated away from doesn't have its (already + unmounted) state mutated once that timer fires. +- **Sidebar search** — reports a debounced BI event once typing pauses, + not per keystroke, so the RUM session's event budget isn't spent on + intermediate keystrokes. +- **Theme options / code-example copy / sidebar collapse** — BI events for + feature adoption tracking (`theme_option_changed`, `code_copied`, + `sidebar_toggled`). +- **Bare `console.warn` calls replaced with the structured logger** in + three places — a route missing a `` (`routing` logger, offending + URL as context), a code example missing both `htmlCode` and `tsCode`, + and a failed clipboard-copy — each now reports through `CpsLoggerService` + instead of an unstructured console line. +- Every component above cancels its own in-flight scenario(s) on + `ngOnDestroy`, so navigating away mid-journey doesn't leave the scenario + registry (or the RUM event budget) accumulating abandoned entries. +- `resolveDeploymentEnvironment()` — this workspace has no + `environment.ts`/`fileReplacements` build variant, so + `provideCpsTelemetry`'s `environment` is derived from + `window.location.hostname` at bootstrap instead of a hardcoded literal: + recognized local-dev hostnames (`localhost`, `127.0.0.1`, `[::1]`, + `0.0.0.0`) resolve to `'development'`, everything else to `'production'`. + The classification rule (`classifyHostname`) is a pure function, unit + tested directly; the thin `window`-reading wrapper around it is not + meaningfully testable under jsdom and isn't. +- `AppLogApiProvider` — an in-memory ring-buffered log backend standing in + for a real endpoint, with a browser-side JSON export. The download's + temporary anchor is created before, and removed in a `finally` alongside + the object URL — not both inside the same `try` after `.click()` — so a + `click()` that throws can't leave the anchor stuck in the DOM forever. +- `AppRumCredentialsProvider` — the credentials-broker integration point, + requesting `/rum/init` with `cache: 'no-store'` since the response + carries live, temporary AWS credentials that must never be served stale + from the browser's HTTP cache. +- `telemetry.schema.ts` — this application's declared scenario/step/logger + vocabulary. + +## Documentation + +- **`DESIGN.md`** — the library's architecture, the reasoning behind each + design decision, and a capability-by-capability verification of what + `aws-rum-web` actually supports, checked against the installed SDK + source rather than assumed from its public documentation. +- **`README.md`** — setup, scenario/logging/BI usage, redaction + configuration, advanced RUM configuration, debugging flags, and testing + guidance for consumers of the library. +- **`telemetry-trace.capture.spec.ts`** (`projects/composition/src/app/`) + — drives `composition`'s real services with + `debugScenario`/`debugLogger`/`debugBI` enabled and captures the exact + payloads handed to the sink and the log API provider, including + synthetic cases (`incomplete`, `exceededStepsLimit`, `mirrorErrorsToRum`, + redaction) as a concrete, reviewable example of the emitted payload + shapes described in `DESIGN.md`. Regenerate with `npm run trace:telemetry`; + the JSON it produces isn't committed (see `.gitignore`). + +## Workspace wiring + +- `angular.json` gains the `cps-telemetry` library project (ng-packagr + build, dev/production `tsconfig` split); `tsconfig.json` adds it to the + workspace's project references. +- `projects/cps-telemetry/rum/ng-package.json` declares the + `cps-telemetry/rum` secondary entry point (ng-packagr discovers it + automatically by scanning the primary package's directory for nested + `ng-package.json` files — no separate `package.json` or `angular.json` + project entry is needed for a secondary entry point). +- `jest.config.js` maps both the `cps-telemetry` and `cps-telemetry/rum` + import specifiers to source, the same way `cps-ui-kit` already is, so + tests and the IDE resolve them without a prior build; `tsconfig.json`'s + `"paths"` gets the matching pair of entries. +- `projects/cps-telemetry/rum/tsconfig.lib.json` / + `tsconfig.spec.json` (new) and their addition to the root `tsconfig.json`'s + `"references"` — ng-packagr generates its own tsconfig per entry point + internally and doesn't need these, but without them nothing covered the + `rum/` directory as a TypeScript project, so `tsc --build` and the IDE's + language service couldn't resolve its `cps-telemetry` import at all. +- CI (`.github/workflows/cps-shared-ui-checkers.yml`) gains a + `build:telemetry` step and a `test:cps-telemetry` step alongside the + existing `cps-ui-kit` ones, plus a `build:documentation` step building + `composition` itself — the one thing in this repo that actually imports + `cps-telemetry`/`cps-telemetry/rum` and exercises the real, bundled + integration; without it, a secondary-entry-point resolution failure or + other integration break could merge with every other check green. +- Root `package.json` gains `build:telemetry`/`test:cps-telemetry` scripts + and `aws-rum-web` as a dependency (for `composition`'s real use of the RUM + sink); `projects/cps-telemetry/package.json` / + `projects/cps-telemetry/ng-package.json` declare the new library's own + metadata, peer dependencies, and ng-packagr output configuration. +- `projects/composition/tsconfig.app.json` gains `skipLibCheck: true` — + needed because `aws-rum-web` pulls in `rrweb` typings that don't compile + under this workspace's TypeScript settings — and an explicit `rootDir: +"../../"`, matching its sibling `tsconfig.spec.json`'s existing setting; + without it, `composition`'s own path-mapped imports of `cps-ui-kit`/ + `cps-telemetry` source (outside its own directory) violated TypeScript's + rootDir check. `projects/composition/tsconfig.json` — the plain + `tsconfig.json` VS Code's language service actually resolves first when a + file is opened, separate from `tsconfig.app.json` — gets both the same + `rootDir` fix and `skipLibCheck: true`, for the identical reasons. + +## Testing + +- **636 tests** in `cps-telemetry`, **264 tests** in `composition`, all + passing. +- Lint clean across the workspace. +- Both production builds (`cps-telemetry` library build, + `composition` documentation-site build) succeed with no compiler or CommonJS warnings. +- The test suite includes mutation-checked coverage of every + concurrency/lifecycle-sensitive path (destroy-time cancellation, + credential-refresh retry with 32-bit overflow protection, the scenario + timeout rescheduling itself (rather than settling early) when a clamped + 32-bit-overflow-protection hop fires before the real configured deadline, + the mark-cleanup fallback timer sharing that same overflow protection + (accepting an early fire there, since it's harmless cleanup hygiene, not + a status determination), `elapsed`'s host-page-relative clock, cross-realm identity sync, redaction ordering, + dedup keying, RxJS operator stream settlement, the leader-election + destroy-while-queued race, `settled$`'s copy-on-emit, the dedup key-cap + eviction-ordering fix, a successful upload settling under `take(1)`, + `traceScenario`'s cancel-on-unsubscribe path, the dedup key's + `JSON.stringify` collision fix, `CpsNoopTelemetrySink`'s `getUserId()` + retention, the download anchor surviving a throwing `click()`, the + initial-load disable-flag parity, the dedup cache's hot-key eviction + fix, the leader election's synchronous-throw fail-open, per-call + `extraValueTransforms` isolation, `correlationId` scrubbing, the + pre-init buffer discard (not just a stopped future-buffering) on a + declined or failed init, the aggregate-freeze-at-settlement fix, the + BI dedup key being built from redacted rather than raw metadata, + `maxKeys` being re-applied to combined scenario/step metadata rather + than to each independently-capped bag before merging, the same-realm + duplicate-host registry, `traceScenario`'s `cancelOutcome` winning + the race against a `switchMap`'s own prior-subscription teardown, the + broadcast wire protocol's `metadata` field being validated as flat + primitives rather than passed through unchecked, the duplicate-host + diagnostic warning itself being fail-open (a patched, throwing + `console.warn` can't crash host construction), and + `AppTelemetryService`'s `NavigationSkipped` handling clearing a stale + click-intent that would otherwise backdate an unrelated navigation) — + each such test + was verified to actually fail when its corresponding guard is removed, + not only to pass against the current implementation. + `CpsRumAppMonitorConfig.clientBuilder`'s completed parameter signature is + verified two ways: a runtime test whose inline `clientBuilder` + implementation leaves `credentials`/`compressionStrategy` unannotated, + relying on the field's own type for contextual inference; and, like + `CpsNoopTelemetrySink`'s preserved method signatures, at the type level + (`tsc --build`) — since ts-jest itself runs with `diagnostics: false`, + neither jest nor this workspace's own `npm run typecheck` would catch a + regression here on its own, see "Known gaps" below. + +### Known gaps + +- **`npm run typecheck` (`tsc --noEmit`) is currently a no-op.** The root + `tsconfig.json` is solution-style — `"files": []` plus per-project + `"references"` — and plain `tsc --noEmit` only checks a config's own + `"files"`/`"include"`, not its references; following references needs + `tsc --build`. Confirmed directly: with a real type error introduced + (temporarily, while verifying the `clientBuilder` fix above), `npm run +typecheck` still exited `0` with no output. `tsc --build` does catch it, + but currently also reports roughly 100 pre-existing errors across the + workspace unrelated to this PR (`aws-rum-web`'s bundled `rrweb` typings; + ambient Node globals `@types/node` would otherwise supply, since this + workspace's `tsconfig`s deliberately omit it — see `cps-telemetry`'s own + `types: ["jest"]` convention). Pre-existing, not introduced by this PR; + switching the script to `tsc --build` would immediately need triaging + those separately. Left as-is here; flagging for a follow-up. diff --git a/angular.json b/angular.json index 44185b048..4af506468 100644 --- a/angular.json +++ b/angular.json @@ -37,6 +37,35 @@ } } }, + "cps-telemetry": { + "projectType": "library", + "root": "projects/cps-telemetry", + "sourceRoot": "projects/cps-telemetry/src", + "prefix": "cps", + "architect": { + "build": { + "builder": "@angular/build:ng-packagr", + "options": { + "project": "projects/cps-telemetry/ng-package.json" + }, + "configurations": { + "production": { + "tsConfig": "projects/cps-telemetry/tsconfig.lib.prod.json" + }, + "development": { + "tsConfig": "projects/cps-telemetry/tsconfig.lib.json" + } + }, + "defaultConfiguration": "production" + }, + "test": { + "builder": "@angular-builders/jest:run", + "options": { + "zoneless": false + } + } + } + }, "composition": { "projectType": "application", "schematics": { @@ -73,6 +102,7 @@ ], "styles": ["projects/composition/src/styles.scss"], "scripts": [], + "allowedCommonJsDependencies": ["shimmer"], "browser": "projects/composition/src/main.ts" }, "configurations": { diff --git a/jest.config.js b/jest.config.js index 7a9cb9d14..032ac792c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -52,7 +52,12 @@ module.exports = { preset: 'jest-preset-angular', moduleNameMapper: { '^lodash-es$': 'lodash', - '^cps-ui-kit$': '<rootDir>/projects/cps-ui-kit/src/public-api.ts' + '^cps-ui-kit$': '<rootDir>/projects/cps-ui-kit/src/public-api.ts', + '^cps-telemetry$': '<rootDir>/projects/cps-telemetry/src/public-api.ts', + '^cps-telemetry/rum$': + '<rootDir>/projects/cps-telemetry/rum/src/public-api.ts', + '^cps-telemetry/diagnostics$': + '<rootDir>/projects/cps-telemetry/diagnostics/src/public-api.ts' }, transformIgnorePatterns: [ 'node_modules/(?!(.*.mjs$|@angular/common/locales/.*.js$))' diff --git a/package-lock.json b/package-lock.json index 18d4492b5..8c0ee490e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@angular/router": "^22.1.3", "@e965/xlsx": "^0.20.3", "@types/lodash-es": "^4.17.12", + "aws-rum-web": "^3.2.1", "highlight.js": "^11.12.0", "lodash-es": "^4.17.21", "rxjs": "~7.8.2", @@ -112,12 +113,12 @@ } }, "node_modules/@angular-devkit/architect": { - "version": "0.2201.8", - "integrity": "sha512-EUQo8RDS1my2Bo5FRS+gBYgz1/klfIp9XESfMpTBO04nBkjF6DkPCeOxeAf1CYjWy9nCXcWn968eSdEhV5jXiA==", + "version": "0.2202.0", + "integrity": "sha512-HxY8JxLd3WxFj45BtDUHZ9I6hO4KebTz32UQ4ZkPh0EMK/TZdspbSgwd2lo1jE6a90knRAQkAsnBTKwWvGSm3A==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "22.1.8", + "@angular-devkit/core": "22.2.0", "rxjs": "7.8.2" }, "bin": { @@ -130,64 +131,63 @@ } }, "node_modules/@angular-devkit/build-angular": { - "version": "22.1.8", - "integrity": "sha512-UH75aJJ5lBpnu3QtC+WygE0Z5dESDLibNQ0jbcot4L7jnT/fLPWbffWp2WHvIjaUybVj/sh441N9rCiy0YMN3Q==", - "deprecated": "Angular's Webpack support is deprecated. Use the esbuild and Vite-based \"@angular/build\" package instead.", + "version": "22.2.0", + "integrity": "sha512-syOm4RmTeFOwhBGJiDr8ZEAFyzXXXPMg7TcXFcBPuaz/Mu7xGMb4CPeEztbVpa6JsZFJU2SPOOI3ZEtidH7RJA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2201.8", - "@angular-devkit/build-webpack": "0.2201.8", - "@angular-devkit/core": "22.1.8", - "@angular/build": "22.1.8", - "@babel/core": "8.0.1", - "@babel/generator": "8.0.0", + "@angular-devkit/architect": "0.2202.0", + "@angular-devkit/build-webpack": "0.2202.0", + "@angular-devkit/core": "22.2.0", + "@angular/build": "22.2.0", + "@babel/core": "8.0.5", + "@babel/generator": "8.0.5", "@babel/helper-annotate-as-pure": "8.0.0", "@babel/helper-split-export-declaration": "7.24.7", "@babel/plugin-transform-async-generator-functions": "8.0.1", "@babel/plugin-transform-async-to-generator": "8.0.1", "@babel/plugin-transform-runtime": "8.0.1", - "@babel/preset-env": "8.0.2", - "@babel/runtime": "8.0.0", + "@babel/preset-env": "8.0.5", + "@babel/runtime": "8.0.5", "@discoveryjs/json-ext": "1.1.0", - "@ngtools/webpack": "22.1.8", + "@ngtools/webpack": "22.2.0", "ansi-colors": "4.1.3", - "autoprefixer": "10.5.4", + "autoprefixer": "10.6.0", "babel-loader": "10.1.1", + "beasties": "0.5.4", "browserslist": "^4.26.0", "copy-webpack-plugin": "14.0.0", - "css-loader": "7.1.4", + "css-loader": "7.1.5", "esbuild-wasm": "0.28.2", "http-proxy-middleware": "4.2.0", "istanbul-lib-instrument": "6.0.3", "jsonc-parser": "3.3.1", "karma-source-map-support": "1.4.0", - "less": "4.9.0", + "less": "4.9.1", "less-loader": "13.0.0", "license-webpack-plugin": "4.0.2", "loader-utils": "3.3.1", "mini-css-extract-plugin": "2.10.2", - "open": "11.0.0", + "open": "11.0.4", "ora": "9.4.1", - "picomatch": "4.0.5", - "piscina": "5.2.0", - "postcss": "8.5.25", + "picomatch": "4.0.7", + "piscina": "5.3.2", + "postcss": "8.5.28", "postcss-loader": "8.2.1", "resolve-url-loader": "5.0.0", "rxjs": "7.8.2", - "sass": "1.101.0", - "sass-loader": "17.0.0", + "sass-loader": "17.0.1", "semver": "7.8.5", "source-map-loader": "5.0.0", "source-map-support": "0.5.21", - "terser": "5.49.0", + "terser": "5.51.2", "tinyglobby": "0.2.17", "tslib": "2.8.1", - "webpack": "5.109.2", - "webpack-dev-middleware": "8.0.3", - "webpack-dev-server": "5.2.6", + "webpack": "5.111.0", + "webpack-dev-middleware": "8.3.0", + "webpack-dev-server": "6.0.0", "webpack-merge": "6.0.1", "webpack-subresource-integrity": "5.1.0" }, @@ -206,7 +206,7 @@ "@angular/platform-browser": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/service-worker": "^22.0.0", - "@angular/ssr": "^22.1.8", + "@angular/ssr": "^22.2.0", "browser-sync": "^3.0.2", "karma": "^6.3.0", "ng-packagr": "^22.0.0", @@ -247,13 +247,13 @@ } }, "node_modules/@angular-devkit/build-webpack": { - "version": "0.2201.8", - "integrity": "sha512-T9Fl7Qcy40UFZaiGAY9ieJC3cgTTupS/eAncKTeuSdqCHpZn+IMhU+Nyl7C2yE4VZe3KCk8CGGKS3g4TmyH/QQ==", + "version": "0.2202.0", + "integrity": "sha512-8vs3uHavunueSKdtROLWWzmGKKNqO673t8blZDkXmHkG2/8i+eu3gCexwu27ZmN1+yIR4UCgJRumWuNb/pHJcQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@angular-devkit/architect": "0.2201.8", + "@angular-devkit/architect": "0.2202.0", "rxjs": "7.8.2" }, "engines": { @@ -263,21 +263,21 @@ }, "peerDependencies": { "webpack": "^5.30.0", - "webpack-dev-server": "^5.0.2" + "webpack-dev-server": "^5.0.2 || ^6.0.0" } }, "node_modules/@angular-devkit/core": { - "version": "22.1.8", - "integrity": "sha512-34lsgg2FwMVBX7nR/ZqzRUcdvdYPvSB8XAEr2GudXAyZwLI9ehzfQlagAWR/gEb8p4uCFZW0r7uU0toxydq4Rw==", + "version": "22.2.0", + "integrity": "sha512-dELBwXtJWUxFALdEVvdk8qK+XBa17EH5H9NZm+041/esjNi6SaZMusqBgr2PZKq7hD+O2jSCEHbey/hwkz2Awg==", "dev": true, "license": "MIT", "dependencies": { "ajv": "8.20.0", "ajv-formats": "3.0.1", "jsonc-parser": "3.3.1", - "picomatch": "4.0.5", + "picomatch": "4.0.7", "rxjs": "7.8.2", - "source-map": "0.7.6" + "source-map": "0.8.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0.0", @@ -294,14 +294,14 @@ } }, "node_modules/@angular-devkit/schematics": { - "version": "22.1.8", - "integrity": "sha512-Pv3cPa/44kvEwYcqwx4Ns5dhIDmcFNSHhbpheqiEG1Z/u4e2t4zHKDtE3eHZB+8o+IcC7xJj+d+AqGR44RoZDA==", + "version": "22.2.0", + "integrity": "sha512-yhp/LRzKHJOzo2gFm9+H5DKnsT9adi+ItO2pW0pW2B455sqZYE8RJaSku+TSlv+PvWSmbtFsPXNgSRLA6pSk8Q==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/core": "22.1.8", + "@angular-devkit/core": "22.2.0", "jsonc-parser": "3.3.1", - "magic-string": "1.0.0", + "magic-string": "1.4.1", "ora": "9.4.1", "rxjs": "7.8.2" }, @@ -312,9 +312,8 @@ } }, "node_modules/@angular/animations": { - "version": "22.1.7", - "integrity": "sha512-ssfo40eCLYNdD1rmN1YKJFpBOF4jR7PwJoCLqp5WtD9KzrXaE+/LPpWAgfVdpCB1vwgON0j6HOV+T0e/0uuPYQ==", - "deprecated": "@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.", + "version": "22.2.0", + "integrity": "sha512-JfBaStn07PC4kIw7vHPlKgyjzIM+d3ZgoxjHuTbLMa6lW1IXmUqgJVNSzgI3TUWmYUBtereMjOnZ2PVuIA+L4g==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -323,41 +322,42 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "@angular/core": "22.1.7" + "@angular/core": "22.2.0" } }, "node_modules/@angular/build": { - "version": "22.1.8", - "integrity": "sha512-tw+Evk0EITb8p8dTu743ONoFztCEiyDkdRbtZsh7C0vBS7Q9ElsOcFeg2Z9OAAwrmtZMIBuW3jrdPRQp7OCSdw==", + "version": "22.2.0", + "integrity": "sha512-9ABYN9zp4DqDfZxiqyIMEB5iec6xIuVys+BfVjOFkwd3tCnOK+DnRtU8OmI7R8BxUZwLzbPqFffZzvCVVfHP7Q==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2201.8", - "@babel/core": "8.0.1", - "@babel/helper-annotate-as-pure": "8.0.0", - "@babel/helper-split-export-declaration": "7.24.7", - "@inquirer/confirm": "6.1.1", + "@angular-devkit/architect": "0.2202.0", + "@babel/core": "8.0.5", + "@inquirer/confirm": "6.3.2", + "@parcel/watcher": "2.6.0", "@vitejs/plugin-basic-ssl": "2.3.0", - "beasties": "0.4.3", + "beasties": "0.5.4", "browserslist": "^4.26.0", + "chokidar": "5.0.0", "esbuild": "0.28.2", "https-proxy-agent": "9.1.0", "jsonc-parser": "3.3.1", - "listr2": "11.0.0", - "magic-string": "1.0.0", + "listr2": "11.1.0", + "magic-string": "1.4.1", "mrmime": "2.0.1", - "oxc-parser": "0.142.0", + "oxc-parser": "0.150.0", "parse5-html-rewriting-stream": "8.0.1", - "picomatch": "4.0.5", - "piscina": "5.2.0", - "rolldown": "1.2.0", - "sass": "1.101.0", + "picomatch": "4.0.7", + "piscina": "5.3.2", + "rolldown": "1.2.8", + "sass": "1.104.1", + "sass-embedded": "1.104.1", "semver": "7.8.5", "source-map-support": "0.5.21", "tinyglobby": "0.2.17", - "vite": "8.1.5", - "watchpack": "2.5.2" + "vite": "8.3.0", + "xxhash-wasm": "1.1.0" }, "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0.0", @@ -375,7 +375,7 @@ "@angular/platform-browser": "^22.0.0", "@angular/platform-server": "^22.0.0", "@angular/service-worker": "^22.0.0", - "@angular/ssr": "^22.1.8", + "@angular/ssr": "^22.2.0", "istanbul-lib-instrument": "^6.0.0", "karma": "^6.4.0", "less": "^4.2.0", @@ -385,7 +385,7 @@ "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", "typescript": ">=6.0 <6.1", - "vitest": "^4.0.8" + "vitest": "^4.0.8 || ^5.0.0" }, "peerDependenciesMeta": { "@angular/core": { @@ -433,18 +433,18 @@ } }, "node_modules/@angular/cli": { - "version": "22.1.8", - "integrity": "sha512-MFN/FydqDndai8vF12hDxG5RlBt1HUUheeJgJNmuHYIne4bOAfOT+xuMpIK8+gLSRwCemdqR3OMM38pnsFJ/nA==", + "version": "22.1.9", + "integrity": "sha512-5GpWeRplkEgJaUYBrnPmqSkPVjmxYSX6mYWjIh/IO9JKm6+gnWDEcTcm//ZdTyG+WnwhpYh/raE1wiyKvkOfew==", "dev": true, "license": "MIT", "dependencies": { - "@angular-devkit/architect": "0.2201.8", - "@angular-devkit/core": "22.1.8", - "@angular-devkit/schematics": "22.1.8", + "@angular-devkit/architect": "0.2201.9", + "@angular-devkit/core": "22.1.9", + "@angular-devkit/schematics": "22.1.9", "@inquirer/prompts": "8.5.2", "@listr2/prompt-adapter-inquirer": "4.2.5", "@modelcontextprotocol/sdk": "1.30.0", - "@schematics/angular": "22.1.8", + "@schematics/angular": "22.1.9", "jsonc-parser": "3.3.1", "listr2": "11.0.0", "npm-package-arg": "14.0.0", @@ -462,9 +462,149 @@ "yarn": ">= 1.13.0" } }, + "node_modules/@angular/cli/node_modules/@angular-devkit/architect": { + "version": "0.2201.9", + "integrity": "sha512-earmt8lj9b/PadJJBTFEd+DMzZKbzaFDdXrTO2zdfmTViuo9OeRkupGQtZkH8KVPMGf9JwNoAvHSAr7k0i3oUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.1.9", + "rxjs": "7.8.2" + }, + "bin": { + "architect": "bin/cli.js" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/cli/node_modules/@angular-devkit/core": { + "version": "22.1.9", + "integrity": "sha512-hO2LID+H7Fsjoz795i96/FcCMGkDe/EQxqjayHwpliVEIoQkwSuW4bZl6y2jfbe/VuXvyQSUkUfhdIeNhG7p+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.20.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.5", + "rxjs": "7.8.2", + "source-map": "0.7.6" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^5.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular/cli/node_modules/@angular-devkit/schematics": { + "version": "22.1.9", + "integrity": "sha512-WksYB3V/BnHztvNbVsv5S5gwQv8PstgDd+gHE6LpOTAr+nqGP7L7BCF/ujF33QEAyjqjA0be8fBqApeWFhtg8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.1.9", + "jsonc-parser": "3.3.1", + "magic-string": "1.0.0", + "ora": "9.4.1", + "rxjs": "7.8.2" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/cli/node_modules/@listr2/prompt-adapter-inquirer": { + "version": "4.2.5", + "integrity": "sha512-pYGy9dTdTwXdasPgyohkr0HoQ4FrkAzFnsUZl/gcnadDArbpZ8e+fgr+F9WBdNEl2y00mb9bCM4WgmoBkZJ27A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/type": "^4.0.7" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "@inquirer/prompts": ">= 3 < 9", + "listr2": "11.0.0" + } + }, + "node_modules/@angular/cli/node_modules/@schematics/angular": { + "version": "22.1.9", + "integrity": "sha512-9C5VuJo3YVQX3oIJ1TG+BAz3bJMkvSm52cz5Uod9prrAMx/CxK4IK7qPH6550igUvRhfkLtELGfkVEaNLC0bOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "22.1.9", + "@angular-devkit/schematics": "22.1.9", + "jsonc-parser": "3.3.1", + "typescript": "6.0.3" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular/cli/node_modules/listr2": { + "version": "11.0.0", + "integrity": "sha512-8K88S0aSrcSXdJfiZtEy5BQMnR+TyjrCGLcgAvQs6ta0NEnIm0RJ72/Pv67Jvg07cfBhDbuN74V81lSSVYEFEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^6.1.1", + "log-update": "^8.0.0", + "wrap-ansi": "^10.0.0" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/@angular/cli/node_modules/magic-string": { + "version": "1.0.0", + "integrity": "sha512-CGvjzMN08iv6w1mm4/x3Gh1hLb4VnyRUA15FFpl6CsCIGGoe36k7kY5KNz9QDbSBN5I/fWHM6ZlIkUTa5xdUEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@angular/cli/node_modules/picomatch": { + "version": "4.0.5", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@angular/cli/node_modules/source-map": { + "version": "0.7.6", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/@angular/common": { - "version": "22.1.7", - "integrity": "sha512-SLW4AdH2Y8G9SM/iFRNpCQtkmvq55RcIQWsADT9ypZ7FxxcnJRsr4Q/WnbUBbuCslsCkrgzU1i9WDiW0m7gcnw==", + "version": "22.2.0", + "integrity": "sha512-BUKkncKwnaEcF/qatBOpzSIx2NthFZLYlTcE7iHwwsw+XQrwB9hU8Ca/l5HqaTJiMg0D8MjI/SWHnD4LFtNN+w==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -473,13 +613,13 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "@angular/core": "22.1.7", + "@angular/core": "22.2.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "22.1.7", - "integrity": "sha512-qhoHAByh805nT4Xy4Wd2kE5FvkTc7wcg8Qps+EH6+z4KRfwGiL5cwdgooGWjTAldFCeqTgJ0BO/0dB6GHZjdkQ==", + "version": "22.2.0", + "integrity": "sha512-bnqql/HfhnhDXoS7isNaXJAsbKYszcH+nFVWA6YhiAVZhqo3f+XoQb30qeqNtEOKuuC7SZfaAJ3jBkAvyC5CKg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -489,8 +629,8 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "22.1.7", - "integrity": "sha512-VGg8Kyt0YymQfnUUFk79vFaM+x5xmIMHy+BZh3P2Lh/uxqCXTWhJJH8kOHKtIGGH5/wKhBEmSy2CezdcIuM9xg==", + "version": "22.2.0", + "integrity": "sha512-xDOvPlNv+RT7sE01wjuGFRMc5SDxWs3RZpGfO5NoI3a0jxM87UW49HmmgwQ6PaU0fya9mttNkHufhfOAbh0ZKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -511,7 +651,7 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "@angular/compiler": "22.1.7", + "@angular/compiler": "22.2.0", "typescript": ">=6.0 <6.1" }, "peerDependenciesMeta": { @@ -520,9 +660,46 @@ } } }, + "node_modules/@angular/compiler-cli/node_modules/@babel/core": { + "version": "8.0.1", + "integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^8.0.0", + "@babel/generator": "^8.0.0", + "@babel/helper-compilation-targets": "^8.0.0", + "@babel/helpers": "^8.0.0", + "@babel/parser": "^8.0.0", + "@babel/template": "^8.0.0", + "@babel/traverse": "^8.0.0", + "@babel/types": "^8.0.0", + "@types/gensync": "^1.0.5", + "convert-source-map": "^2.0.0", + "empathic": "^2.0.1", + "gensync": "^1.0.0-beta.2", + "import-meta-resolve": "^4.2.0", + "json5": "^2.2.3", + "obug": "^2.1.1", + "semver": "^7.7.3" + }, + "engines": { + "node": "^22.18.0 || >=24.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/@angular/core": { - "version": "22.1.7", - "integrity": "sha512-T9bt1PIgoN7n+TxGHTUgu0EsomlGMQ84YMXbMB+N6HhD8C1qZxn0uCbnAtCUjcecg1am8ioKxNTO0eII9ouioQ==", + "version": "22.2.0", + "integrity": "sha512-VhAgJ4GCdGgAy9+zADpSyEM9gt4pKg6+cqNeK3RBNeF3M3B8y7AWfD/nYRdM3Xzi152Pu+ipgFFNHpupCLTsvQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -531,7 +708,7 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "@angular/compiler": "22.1.7", + "@angular/compiler": "22.2.0", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0 || ~0.16.0" }, @@ -545,8 +722,8 @@ } }, "node_modules/@angular/forms": { - "version": "22.1.7", - "integrity": "sha512-oc0DT39C3ZboJpDx8xKUCltgn56LHi0kbv+ThNK/dCSZLgjF2nd+muMMRYF+amdljQp2q7+hm2ORmhON3CNFog==", + "version": "22.2.0", + "integrity": "sha512-Nm8o1uBk31EuQGzsK5EunNBVFzTN9MuTYLSrmg1SCPTcava4qK3AzSpl1+Put+cZTTnGtEBIy45AFaxHR2oryg==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -557,15 +734,15 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "@angular/common": "22.1.7", - "@angular/core": "22.1.7", - "@angular/platform-browser": "22.1.7", + "@angular/common": "22.2.0", + "@angular/core": "22.2.0", + "@angular/platform-browser": "22.2.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/platform-browser": { - "version": "22.1.7", - "integrity": "sha512-4dT3qGw3ZHVQUcHKALzMx9MTo+pxAMzHIPxc8HUESklqXfq6GRaIIfFMvE0RsquI9iqu6admD6lOAEzLWS8DxA==", + "version": "22.2.0", + "integrity": "sha512-XMUOyn8WARk9NdFGO3uRgdC3hPrp+2xsSYNeSkaEZ4jI3Pc23f22e4GI8Vayl/UOmShHVn/i+NWfTWh3SEbbSw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -574,9 +751,9 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "@angular/animations": "22.1.7", - "@angular/common": "22.1.7", - "@angular/core": "22.1.7" + "@angular/animations": "22.2.0", + "@angular/common": "22.2.0", + "@angular/core": "22.2.0" }, "peerDependenciesMeta": { "@angular/animations": { @@ -585,9 +762,8 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "22.1.7", - "integrity": "sha512-p1092gjR4ZB8U3h64OIkyrR8xu/5xTwa5VsGLkLhgQOa+6v/7OLhWRVzrN/7Oe+jBU6rQt6E+qr0gjeo2ETDbQ==", - "deprecated": "@angular/platform-browser-dynamic is deprecated. Use `@angular/platform-browser` instead.", + "version": "22.2.0", + "integrity": "sha512-KG/m6Jz3tKAeXWFYFgEISiEsWSBQusemI+/uafmq2GYiI4cZneCeW35v7mHujgFzVH8jzyJwPyPQtpnKDdGg6g==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -596,15 +772,15 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "@angular/common": "22.1.7", - "@angular/compiler": "22.1.7", - "@angular/core": "22.1.7", - "@angular/platform-browser": "22.1.7" + "@angular/common": "22.2.0", + "@angular/compiler": "22.2.0", + "@angular/core": "22.2.0", + "@angular/platform-browser": "22.2.0" } }, "node_modules/@angular/platform-server": { - "version": "22.1.7", - "integrity": "sha512-nSIqfspoIFkFoEDG8qf1VAzgrU+ueLwhGwbaXHs2Gq7G72i/qcTsLTdqOtSGTITsHopH1F9f4G6BEE+SQI29Cg==", + "version": "22.2.0", + "integrity": "sha512-Ppb0Zqr0lLubAW2UvjsNYDuoh+WX8ZeKdrmEMa8qhpVIxD1UL6CAQnKSlQQabWfuCQOphzBi7u7QeotGsTajKQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0", @@ -614,16 +790,16 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "@angular/common": "22.1.7", - "@angular/compiler": "22.1.7", - "@angular/core": "22.1.7", - "@angular/platform-browser": "22.1.7", + "@angular/common": "22.2.0", + "@angular/compiler": "22.2.0", + "@angular/core": "22.2.0", + "@angular/platform-browser": "22.2.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/router": { - "version": "22.1.7", - "integrity": "sha512-CxYa2Ym5L0WmqToHU2I0Z4VEpocdryLlvx5svLfo9M3uOaP3/9icB/gjS/vXPbbQxV8/GZ59E/7/LF14anIoRg==", + "version": "22.2.0", + "integrity": "sha512-+pxHMwWWKg+ejS1vKa+tS91CDusCIK1MOSy6fVLc89McMOY/C8tsLrh1uJaMjklejsz+3XUhJgrLOvl3ISNz6w==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -632,32 +808,32 @@ "node": "^22.22.3 || ^24.15.0 || >=26.0.0" }, "peerDependencies": { - "@angular/common": "22.1.7", - "@angular/core": "22.1.7", - "@angular/platform-browser": "22.1.7", + "@angular/common": "22.2.0", + "@angular/core": "22.2.0", + "@angular/platform-browser": "22.2.0", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@asamuzakjp/css-color": { - "version": "7.0.0", - "integrity": "sha512-IxddtGnnoidVSO6O/aiTueptbTThHvi2qGHu9/AWuD1J7i86Ox+jl+Y5SXcDH1RixzQlP2cjH/PjnlImZiAurQ==", + "version": "7.1.2", + "integrity": "sha512-99DHAnXDB5z6EEK+9GMpVI7Mw4oxj97dY5bpOzMnjADQWxI8rN6TvTduuFLUhUMlS7/CfVZ06tcZsus6cltnNw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@csstools/css-calc": "^3.3.0", - "@csstools/css-color-parser": "^4.2.0", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.5.2" + "@csstools/css-calc": "^3.4.1", + "@csstools/css-color-parser": "^4.2.4", + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" }, "engines": { "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "9.2.0", - "integrity": "sha512-qKvLetaSd/OTWif9JT0eu1SHBg3H7Nvp9SoMwMxqT5tmYyrxea/P1A5pIfGbnIJlOtm3yVeqw+ORxDOkjN1aEw==", + "version": "9.2.2", + "integrity": "sha512-lSWTBMjAcmu2xn5yEDU7jh6QDV+C8GEKtdJ4pIQhXh26RkKQ7S3FuQlt+zZkUbTdJ4d3XyV1kPI/G0zWXxqaqw==", "dev": true, "license": "MIT", "peer": true, @@ -665,12 +841,73 @@ "bidi-js": "^1.1.0", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.5.2" + "lru-cache": "^11.5.3" }, "engines": { "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-rum/web-core": { + "version": "3.2.1", + "integrity": "sha512-i9wZrQYAaJNueBbHJsH04eFn7q+umtOmsm21i9kur2YYCsvGhOFADifbBW1vpv0TfO1nKfHjPMdbvb9AeiKVOA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@rrweb/record": "2.0.0-alpha.20", + "@smithy/fetch-http-handler": "^5.0.0", + "@smithy/protocol-http": "^5.0.0", + "@smithy/querystring-builder": "^4.0.0", + "@smithy/signature-v4": "^5.0.0", + "@smithy/util-hex-encoding": "^3.0.0", + "rrweb": "2.0.0-alpha.4", + "shimmer": "^1.2.1", + "uuid": "^9.0.0", + "web-vitals": "^4.0.0" + } + }, + "node_modules/@aws-rum/web-slim": { + "version": "3.2.1", + "integrity": "sha512-CbNAuqWFvQ2VlA6m/aWvPylkT0TR+vBIP8RtP1/nnxinJah42vgIb4EbwFX8tAkzWkOIOpAqj0EeqTs9RopsbA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-rum/web-core": "3.2.1" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.6", + "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@axe-core/playwright": { "version": "4.13.0", "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==", @@ -706,19 +943,19 @@ } }, "node_modules/@babel/core": { - "version": "8.0.1", - "integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==", + "version": "8.0.5", + "integrity": "sha512-2/oWkgTbBYoqioCWAE4XJobOrzwxTDa5/XjDP3tJ1BhDr/owcd9qnXBp4xc3/2G5X4bvXM04nrxbRJxFcXAxFQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^8.0.0", - "@babel/generator": "^8.0.0", - "@babel/helper-compilation-targets": "^8.0.0", - "@babel/helpers": "^8.0.0", - "@babel/parser": "^8.0.0", + "@babel/generator": "^8.0.5", + "@babel/helper-compilation-targets": "^8.0.5", + "@babel/helpers": "^8.0.5", + "@babel/parser": "^8.0.5", "@babel/template": "^8.0.0", - "@babel/traverse": "^8.0.0", - "@babel/types": "^8.0.0", + "@babel/traverse": "^8.0.5", + "@babel/types": "^8.0.5", "@types/gensync": "^1.0.5", "convert-source-map": "^2.0.0", "empathic": "^2.0.1", @@ -726,7 +963,7 @@ "import-meta-resolve": "^4.2.0", "json5": "^2.2.3", "obug": "^2.1.1", - "semver": "^7.7.3" + "verkit": "^0.3.2" }, "engines": { "node": "^22.18.0 || >=24.11.0" @@ -743,15 +980,15 @@ "license": "MIT" }, "node_modules/@babel/generator": { - "version": "8.0.0", - "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", + "version": "8.0.5", + "integrity": "sha512-f/TuhuMAxJqhwxEGNsJrswuG9VHmh0oNFoQoo6TbpgtFAz9wYZXcTAcWZMHfp7ljesr0RG04bp3Aos9GI59L7w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^8.0.0", - "@babel/types": "^8.0.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", + "@babel/parser": "^8.0.5", + "@babel/types": "^8.0.5", + "@jridgewell/gen-mapping": "0.4.0-beta.0", + "@jridgewell/trace-mapping": "^0.3.31", "@types/jsesc": "^2.5.0", "jsesc": "^3.0.2" }, @@ -759,11 +996,22 @@ "node": "^22.18.0 || >=24.11.0" } }, + "node_modules/@babel/generator/node_modules/@jridgewell/gen-mapping": { + "version": "0.4.0-beta.0", + "integrity": "sha512-JdGNkbE4GlNPYQhM0L95fBQr7ctLZJ276QXQLTad4t1oSdnnCI3fDq9DW3BqYAWv8Wc3+HS+4Gsii1oPMCfz1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0-beta.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@babel/helper-annotate-as-pure": { "version": "8.0.0", "integrity": "sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^8.0.0" }, @@ -981,6 +1229,7 @@ "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.24.7" }, @@ -993,6 +1242,7 @@ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1002,6 +1252,7 @@ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1011,6 +1262,7 @@ "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" @@ -2057,37 +2309,37 @@ } }, "node_modules/@babel/preset-env": { - "version": "8.0.2", - "integrity": "sha512-CUGLn9hNBCF/eXnwdFAWERbniCcXCRvnKwLV9fegeUEIqv7YlU2MepsWMMM54GcILx5XYMnRh+JAL+K5G+mK6g==", + "version": "8.0.5", + "integrity": "sha512-0ZiW2jOnEQ3+FV3ro3uh6B5afBT0C2yJHCKvw+s1BOKKXg7VfCvTpGBylN5UFeO2SFtkVJB2KY0tEpdnUjjguw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@babel/compat-data": "^8.0.0", - "@babel/helper-compilation-targets": "^8.0.0", + "@babel/compat-data": "^8.0.5", + "@babel/helper-compilation-targets": "^8.0.5", "@babel/helper-plugin-utils": "^8.0.1", "@babel/helper-validator-option": "^8.0.0", "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^8.0.1", "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^8.0.1", "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^8.0.1", - "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^8.0.1", + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^8.0.5", "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^8.0.1", "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^8.0.1", "@babel/plugin-transform-arrow-functions": "^8.0.1", "@babel/plugin-transform-async-generator-functions": "^8.0.1", "@babel/plugin-transform-async-to-generator": "^8.0.1", "@babel/plugin-transform-block-scoped-functions": "^8.0.1", - "@babel/plugin-transform-block-scoping": "^8.0.1", + "@babel/plugin-transform-block-scoping": "^8.0.5", "@babel/plugin-transform-class-properties": "^8.0.1", - "@babel/plugin-transform-class-static-block": "^8.0.1", - "@babel/plugin-transform-classes": "^8.0.1", + "@babel/plugin-transform-class-static-block": "^8.0.5", + "@babel/plugin-transform-classes": "^8.0.5", "@babel/plugin-transform-computed-properties": "^8.0.1", - "@babel/plugin-transform-destructuring": "^8.0.1", + "@babel/plugin-transform-destructuring": "^8.0.5", "@babel/plugin-transform-dotall-regex": "^8.0.1", "@babel/plugin-transform-duplicate-keys": "^8.0.1", "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^8.0.1", "@babel/plugin-transform-dynamic-import": "^8.0.1", - "@babel/plugin-transform-explicit-resource-management": "^8.0.1", + "@babel/plugin-transform-explicit-resource-management": "^8.0.5", "@babel/plugin-transform-exponentiation-operator": "^8.0.1", "@babel/plugin-transform-export-namespace-from": "^8.0.1", "@babel/plugin-transform-for-of": "^8.0.1", @@ -2107,16 +2359,16 @@ "@babel/plugin-transform-object-rest-spread": "^8.0.1", "@babel/plugin-transform-object-super": "^8.0.1", "@babel/plugin-transform-optional-catch-binding": "^8.0.1", - "@babel/plugin-transform-optional-chaining": "^8.0.1", - "@babel/plugin-transform-parameters": "^8.0.1", + "@babel/plugin-transform-optional-chaining": "^8.0.5", + "@babel/plugin-transform-parameters": "^8.0.5", "@babel/plugin-transform-private-methods": "^8.0.1", - "@babel/plugin-transform-private-property-in-object": "^8.0.1", + "@babel/plugin-transform-private-property-in-object": "^8.0.5", "@babel/plugin-transform-property-literals": "^8.0.1", - "@babel/plugin-transform-regenerator": "^8.0.2", + "@babel/plugin-transform-regenerator": "^8.0.5", "@babel/plugin-transform-regexp-modifiers": "^8.0.1", "@babel/plugin-transform-reserved-words": "^8.0.1", "@babel/plugin-transform-shorthand-properties": "^8.0.1", - "@babel/plugin-transform-spread": "^8.0.1", + "@babel/plugin-transform-spread": "^8.0.5", "@babel/plugin-transform-sticky-regex": "^8.0.1", "@babel/plugin-transform-template-literals": "^8.0.1", "@babel/plugin-transform-typeof-symbol": "^8.0.1", @@ -2127,7 +2379,7 @@ "@babel/preset-modules": "^0.2.0", "babel-plugin-polyfill-corejs3": "^1.0.0", "core-js-compat": "^3.48.0", - "semver": "^7.7.3" + "verkit": "^0.3.2" }, "engines": { "node": "^22.18.0 || >=24.11.0" @@ -2154,8 +2406,8 @@ } }, "node_modules/@babel/runtime": { - "version": "8.0.0", - "integrity": "sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==", + "version": "8.0.5", + "integrity": "sha512-7NK+Lz3spQ52XsUGTxIEVU4jYN2/dIaX8sTxRFAUtYmttYZnVh3aehiihv+/Gb7+duMNHl2OrFcBQRyOHScxpg==", "dev": true, "license": "MIT", "peer": true @@ -2251,9 +2503,15 @@ "specificity": "bin/cli.js" } }, + "node_modules/@bufbuild/protobuf": { + "version": "2.16.0", + "integrity": "sha512-FWa0sPlqYGJgpTs6OxBRcL/AW4JT0OIO+W1ajerluoTVh8CV0B7XM1qsNCRgnkSCkRCgOICKHRQlY17jrsm0+Q==", + "dev": true, + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, "node_modules/@csstools/color-helpers": { - "version": "6.1.1", - "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "version": "6.1.2", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", "dev": true, "funding": [ { @@ -2272,8 +2530,8 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.4.0", - "integrity": "sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==", + "version": "3.4.1", + "integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==", "dev": true, "funding": [ { @@ -2291,13 +2549,13 @@ "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-color-parser": { - "version": "4.2.3", - "integrity": "sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==", + "version": "4.2.4", + "integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==", "dev": true, "funding": [ { @@ -2312,20 +2570,20 @@ "license": "MIT", "peer": true, "dependencies": { - "@csstools/color-helpers": "^6.1.1", - "@csstools/css-calc": "^3.4.0" + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.1" }, "engines": { "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "version": "4.0.1", + "integrity": "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==", "dev": true, "funding": [ { @@ -2343,7 +2601,7 @@ "node": ">=20.19.0" }, "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-tokenizer": "^4.0.2" } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { @@ -2372,8 +2630,8 @@ } }, "node_modules/@csstools/css-tokenizer": { - "version": "4.0.1", - "integrity": "sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==", + "version": "4.0.2", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", "dev": true, "funding": [ { @@ -2413,19 +2671,19 @@ } }, "node_modules/@emnapi/core": { - "version": "1.11.2", - "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "version": "1.10.0", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.2", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "version": "1.10.0", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -2434,8 +2692,8 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "version": "1.2.1", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -2978,8 +3236,8 @@ } }, "node_modules/@exodus/bytes": { - "version": "1.15.1", - "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "version": "1.16.0", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", "dev": true, "license": "MIT", "peer": true, @@ -3016,8 +3274,8 @@ "optional": true }, "node_modules/@hono/node-server": { - "version": "2.1.1", - "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "version": "2.1.3", + "integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==", "dev": true, "license": "MIT", "engines": { @@ -3122,40 +3380,14 @@ } } }, - "node_modules/@inquirer/checkbox/node_modules/@inquirer/core": { - "version": "12.0.3", - "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.8", - "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/@inquirer/confirm": { - "version": "6.1.1", - "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", + "version": "6.3.2", + "integrity": "sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^11.2.1", - "@inquirer/type": "^4.0.7" + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -3170,14 +3402,14 @@ } }, "node_modules/@inquirer/core": { - "version": "11.2.1", - "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "version": "12.0.3", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/figures": "^2.0.7", - "@inquirer/type": "^4.0.7", + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", "cli-width": "^4.1.0", "fast-wrap-ansi": "^0.2.0", "mute-stream": "^3.0.0", @@ -3217,32 +3449,6 @@ } } }, - "node_modules/@inquirer/editor/node_modules/@inquirer/core": { - "version": "12.0.3", - "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.8", - "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/@inquirer/expand": { "version": "5.1.5", "integrity": "sha512-uHuXLmXW+TtIfT/9vSBotypAkqn1n34Ul+CLGPos/xANyO4Ff5xZzkYhbKR4NEcfVK4a9mHQOpwVZzluSHFRGw==", @@ -3264,32 +3470,6 @@ } } }, - "node_modules/@inquirer/expand/node_modules/@inquirer/core": { - "version": "12.0.3", - "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.8", - "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/@inquirer/external-editor": { "version": "3.0.5", "integrity": "sha512-f3QQJRIX5ZEneBHNUIuPjmbdzHnmRFJA8r2dkcb8q+OM5Uv5KtnuAttQumnrjcBVBM3mcTX1CkmtAkU58VRZxg==", @@ -3341,32 +3521,6 @@ } } }, - "node_modules/@inquirer/input/node_modules/@inquirer/core": { - "version": "12.0.3", - "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.8", - "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/@inquirer/number": { "version": "4.2.3", "integrity": "sha512-6Yuwh1NGSbu1Lo4N1EWjXs1jKRntLg/ZCwhmeorEHde90v1XxAozdbd4Iu30eOQLW+6h1hp2O9ujNfLSbTPJnA==", @@ -3388,32 +3542,6 @@ } } }, - "node_modules/@inquirer/number/node_modules/@inquirer/core": { - "version": "12.0.3", - "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.8", - "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/@inquirer/password": { "version": "5.2.2", "integrity": "sha512-W9zYdyzogK+6110mqwaSJWCBu2yA5Q/OfnGSjjZB1bNpHlmUozXxTl0+QOZBNeVd6Qo81/qT75gW05gLAtITxw==", @@ -3436,32 +3564,6 @@ } } }, - "node_modules/@inquirer/password/node_modules/@inquirer/core": { - "version": "12.0.3", - "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.8", - "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/@inquirer/prompts": { "version": "8.5.2", "integrity": "sha512-IYR/3C/paEVVQYQvdDlFZVjRCJVYHHON0XXMH91KO9GSxs0TdKYWlUdvfQl2EfAHDxUaN3IBffkE/BDTh5nJ6g==", @@ -3512,32 +3614,6 @@ } } }, - "node_modules/@inquirer/rawlist/node_modules/@inquirer/core": { - "version": "12.0.3", - "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.8", - "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/@inquirer/search": { "version": "4.3.3", "integrity": "sha512-fyuIU1Nbpvwlikjg3gXwJFDI11+EFjqQ7P+iByfmivIKQ1vmaykNrD/vy5unHuUqUpsOsnvJ25//tPF7E/RBRA==", @@ -3560,32 +3636,6 @@ } } }, - "node_modules/@inquirer/search/node_modules/@inquirer/core": { - "version": "12.0.3", - "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.8", - "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/@inquirer/select": { "version": "5.2.5", "integrity": "sha512-9kc15hr8r/kI+3DO/xLog5nOzTz1jqsHXa6JBFzmQKhkoJ8Slda1I1L/uD8ZSZ9tF1yp79wwXe7mclvX1rqR2Q==", @@ -3609,32 +3659,6 @@ } } }, - "node_modules/@inquirer/select/node_modules/@inquirer/core": { - "version": "12.0.3", - "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.8", - "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, "node_modules/@inquirer/type": { "version": "4.1.1", "integrity": "sha512-yJoHYrMnxIsJZCY+0Vb66Dy3he3kL3e2wOBKhoSwWWAzZAY82emlxwgprCtp6yRixvNRNq9ztfRWQYPNr3Go7A==", @@ -3670,8 +3694,8 @@ } }, "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.3.0", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "version": "6.4.0", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", "dev": true, "license": "MIT", "engines": { @@ -4589,14 +4613,14 @@ } }, "node_modules/@jsonjoy.com/fs-core": { - "version": "4.78.1", - "integrity": "sha512-UUGcxEah6eb6PyrF8RDEuVgR0Cs2K6XTiLH7YxNR3DknL3wZCNx1nU5m2Gq5Wq8ipy4uHDay3xt/50KyoTN6Zg==", + "version": "4.80.0", + "integrity": "sha512-qMKWshnyjbyhm+NbzqGE3w5y1mckFFEMj0aDPU2/JBNh1BzIjVy9esq9lBmRnNVmhMEkxwDzLZ8Gqh5G1P5Dag==", "dev": true, "license": "Apache-2.0", "peer": true, "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.78.1", - "@jsonjoy.com/fs-node-utils": "4.78.1", + "@jsonjoy.com/fs-node-builtins": "4.80.0", + "@jsonjoy.com/fs-node-utils": "4.80.0", "thingies": "^2.5.0" }, "engines": { @@ -4611,15 +4635,15 @@ } }, "node_modules/@jsonjoy.com/fs-fsa": { - "version": "4.78.1", - "integrity": "sha512-PaoXI4HAELQ/DYxCtqHne7ijrTE0BIEFYXI/qTy4f6pThXiYT5QyJkk96bzpcjdo2D+OM0pUv7mkwffSAl/GSQ==", + "version": "4.80.0", + "integrity": "sha512-ZBEKl7J6dbkRCfmrFV48Re211A/FHqniTUhsWCsaRteNSOqMgkX3qF1UreVgwB9oSPm81JLAwhIi6Lb/NH/PoA==", "dev": true, "license": "Apache-2.0", "peer": true, "dependencies": { - "@jsonjoy.com/fs-core": "4.78.1", - "@jsonjoy.com/fs-node-builtins": "4.78.1", - "@jsonjoy.com/fs-node-utils": "4.78.1", + "@jsonjoy.com/fs-core": "4.80.0", + "@jsonjoy.com/fs-node-builtins": "4.80.0", + "@jsonjoy.com/fs-node-utils": "4.80.0", "thingies": "^2.5.0" }, "engines": { @@ -4634,17 +4658,17 @@ } }, "node_modules/@jsonjoy.com/fs-node": { - "version": "4.78.1", - "integrity": "sha512-3z6o4qVdKUZTHNXiV+3bKAUkQSD5a2kFwwwag8fe6VvgZ4WNfDbMzWdGvoGx+oRpOcr1iWA9WYwVeVMjSir4mA==", + "version": "4.80.0", + "integrity": "sha512-yiBlUfkMMRFFGy8CD/h2zd5rPnrM6bjlF+nx2LRAxNV6hOSiuObQIUEE7m7y2VTMisjxCRtzINH94bFGySWL7g==", "dev": true, "license": "Apache-2.0", "peer": true, "dependencies": { - "@jsonjoy.com/fs-core": "4.78.1", - "@jsonjoy.com/fs-node-builtins": "4.78.1", - "@jsonjoy.com/fs-node-utils": "4.78.1", - "@jsonjoy.com/fs-print": "4.78.1", - "@jsonjoy.com/fs-snapshot": "4.78.1", + "@jsonjoy.com/fs-core": "4.80.0", + "@jsonjoy.com/fs-node-builtins": "4.80.0", + "@jsonjoy.com/fs-node-utils": "4.80.0", + "@jsonjoy.com/fs-print": "4.80.0", + "@jsonjoy.com/fs-snapshot": "4.80.0", "glob-to-regex.js": "^1.3.1", "thingies": "^2.5.0" }, @@ -4660,8 +4684,8 @@ } }, "node_modules/@jsonjoy.com/fs-node-builtins": { - "version": "4.78.1", - "integrity": "sha512-yaGM1l77yYANFl+w2gdixRZhcQhKodtbI4FZgJg2ApfTTSJcxdYEekU+Ta92sCqSwrOtY+ru4IP81tgJxWXIyA==", + "version": "4.80.0", + "integrity": "sha512-OIOIhqaWiwUySFMny4epIAoviXsyMaQQ63PTc3zH+5HEOvarl5hpUmZIhp2m5+OfyKAh5ylxXnQIgew1OgJDVw==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -4677,15 +4701,15 @@ } }, "node_modules/@jsonjoy.com/fs-node-to-fsa": { - "version": "4.78.1", - "integrity": "sha512-/Z00TkjLD8zPRimbINaiAQRqpE9HZmiGfxQFMqNcHncmhijS3JsAW21gcNO/16jujoDwXOyEaSSeK+pBtv8QHQ==", + "version": "4.80.0", + "integrity": "sha512-ljeaR5xwtX1EyRmB0wvXANUMQZ3mjKcm7z8s2N8D8SLQ04CcAGtUhHFRZQXFb9mhgFyEVMydQGQtSOFS7pcrFQ==", "dev": true, "license": "Apache-2.0", "peer": true, "dependencies": { - "@jsonjoy.com/fs-fsa": "4.78.1", - "@jsonjoy.com/fs-node-builtins": "4.78.1", - "@jsonjoy.com/fs-node-utils": "4.78.1" + "@jsonjoy.com/fs-fsa": "4.80.0", + "@jsonjoy.com/fs-node-builtins": "4.80.0", + "@jsonjoy.com/fs-node-utils": "4.80.0" }, "engines": { "node": ">=10.0" @@ -4699,13 +4723,13 @@ } }, "node_modules/@jsonjoy.com/fs-node-utils": { - "version": "4.78.1", - "integrity": "sha512-wm0PVK6XpjHH6GLGVi60+ubD7pNv91KwQ8SBWwn+orRTYdfEIci59/VlOddIGhUSde0t9W9gLNetlPKQHQJsSQ==", + "version": "4.80.0", + "integrity": "sha512-xwHeVVi3f+Khg/Fmb8i5qoYVmQ08QznE5TWhQokUmIZjz4KBPzU+zt/+bahBI6G1ghtqajt27lWuAciIxApuzA==", "dev": true, "license": "Apache-2.0", "peer": true, "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.78.1", + "@jsonjoy.com/fs-node-builtins": "4.80.0", "glob-to-regex.js": "^1.3.1" }, "engines": { @@ -4720,13 +4744,13 @@ } }, "node_modules/@jsonjoy.com/fs-print": { - "version": "4.78.1", - "integrity": "sha512-NUwSD+BOz0TUpBc+riQpI1G1vOhViSZePeqUPxKznelsk3Iwotu6T/02UAY9WdaGcLdlDX8pllEIyxehnYo9Ng==", + "version": "4.80.0", + "integrity": "sha512-1ocZLJw/m/xlq0moaIHO7HV9nHRVBUvdc2zMzhsJGkFOrqfwWvexfCv5cYCPPLRhP+9p3yunoi4Ep5YhRORC9Q==", "dev": true, "license": "Apache-2.0", "peer": true, "dependencies": { - "@jsonjoy.com/fs-node-utils": "4.78.1", + "@jsonjoy.com/fs-node-utils": "4.80.0", "tree-dump": "^1.1.0" }, "engines": { @@ -4741,14 +4765,14 @@ } }, "node_modules/@jsonjoy.com/fs-snapshot": { - "version": "4.78.1", - "integrity": "sha512-gejpLh45sgYGEHWZP/LP2etxaUOYawDfbFVckl9xDDEvgSAtZf700MzCAGbPBPBzRL+qW5UDDwacIgYXHY3C9Q==", + "version": "4.80.0", + "integrity": "sha512-3AV23Q1H228KTtJKTUE0nNq8FCtxSvtENOLrEat80wkAJCKHuqQEv8yjhpS8Bei2OEFJAnluGXp9Ri2SWBlROw==", "dev": true, "license": "Apache-2.0", "peer": true, "dependencies": { "@jsonjoy.com/buffers": "^17.65.0", - "@jsonjoy.com/fs-node-utils": "4.78.1", + "@jsonjoy.com/fs-node-utils": "4.80.0", "@jsonjoy.com/json-pack": "^17.65.0", "@jsonjoy.com/util": "^17.65.0" }, @@ -4975,28 +4999,12 @@ "license": "MIT", "peer": true }, - "node_modules/@listr2/prompt-adapter-inquirer": { - "version": "4.2.5", - "integrity": "sha512-pYGy9dTdTwXdasPgyohkr0HoQ4FrkAzFnsUZl/gcnadDArbpZ8e+fgr+F9WBdNEl2y00mb9bCM4WgmoBkZJ27A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/type": "^4.0.7" - }, - "engines": { - "node": ">=22.13.0" - }, - "peerDependencies": { - "@inquirer/prompts": ">= 3 < 9", - "listr2": "11.0.0" - } - }, - "node_modules/@lmdb/lmdb-darwin-arm64": { - "version": "3.5.6", - "integrity": "sha512-mY5FG4TjPAkY4P0w+OhHaUka5mDh2TX2WKYIwuKzJ1zeW3VvRgxdam/lGJTquI+bthTx5CSHDW+BAQCnNAzkEA==", - "cpu": [ - "arm64" - ], + "node_modules/@lmdb/lmdb-darwin-arm64": { + "version": "3.5.6", + "integrity": "sha512-mY5FG4TjPAkY4P0w+OhHaUka5mDh2TX2WKYIwuKzJ1zeW3VvRgxdam/lGJTquI+bthTx5CSHDW+BAQCnNAzkEA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, @@ -5200,22 +5208,6 @@ "win32" ] }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" - } - }, "node_modules/@napi-rs/nice": { "version": "1.1.1", "integrity": "sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==", @@ -5543,9 +5535,8 @@ } }, "node_modules/@ngtools/webpack": { - "version": "22.1.8", - "integrity": "sha512-U8VA7iaxDjSWaUOmYmcK1JjrQczoGQeq4QbBwFHo1ET3ArshIDtaiXkvuIwUDHBL4N2yGHAgEZZee8ChJvz77w==", - "deprecated": "Angular's Webpack support is deprecated. Use the esbuild and Vite-based \"@angular/build\" package instead.", + "version": "22.2.0", + "integrity": "sha512-p71fd66PAkPGQCxYuaxFFSGPUOGdLsE3af/yXAMshUE6+XJyJp7RIOYoyu1dLuAOjrYpua+lqj/yl/ldOVu+Jg==", "dev": true, "license": "MIT", "peer": true, @@ -5560,6 +5551,19 @@ "webpack": "^5.54.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", @@ -5596,8 +5600,8 @@ } }, "node_modules/@oxc-parser/binding-android-arm-eabi": { - "version": "0.142.0", - "integrity": "sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==", + "version": "0.150.0", + "integrity": "sha512-oQef2Zu4Prz1KLKznz3HqZzU9uVoA5PMoDZuuLmqms7hKmKSAPzlaMnLllJq3t+rgKmfJJ2siPrpZfFrW06btw==", "cpu": [ "arm" ], @@ -5612,8 +5616,8 @@ } }, "node_modules/@oxc-parser/binding-android-arm64": { - "version": "0.142.0", - "integrity": "sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==", + "version": "0.150.0", + "integrity": "sha512-B6ofpoFiAUwIZ0MJ2IgHPvZK8FAtL4qzSZRwOkAKIfxEobE1mQC8nDdiFZj7pUaJiVUVCsfkMsBJKedzO8rZvA==", "cpu": [ "arm64" ], @@ -5628,8 +5632,8 @@ } }, "node_modules/@oxc-parser/binding-darwin-arm64": { - "version": "0.142.0", - "integrity": "sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==", + "version": "0.150.0", + "integrity": "sha512-J+9IHKzx/bSz1JetOfD4zKXSK9sOm4/a7+0qomJODcTL6JsRXGeV/ZdkAPSIDydfFmWzYCraMlQEosSZEyBYkQ==", "cpu": [ "arm64" ], @@ -5644,8 +5648,8 @@ } }, "node_modules/@oxc-parser/binding-darwin-x64": { - "version": "0.142.0", - "integrity": "sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==", + "version": "0.150.0", + "integrity": "sha512-v6IPfcAcSYrBWXV5Tce1DmxDMXLhEYpIIWiRFPJopgCVscZdLaV4MRQOUxvL3usQlw+yrwYOjBFB9IwBx+jUiQ==", "cpu": [ "x64" ], @@ -5660,8 +5664,8 @@ } }, "node_modules/@oxc-parser/binding-freebsd-x64": { - "version": "0.142.0", - "integrity": "sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==", + "version": "0.150.0", + "integrity": "sha512-AoR/4jD02HET0KO0yNT4nbTE+XJUxiO9jB1X/ycEjUE3WrIJ3IjV/Vf+gskhWLcqqeXfvNnkDtBR7epllAm88A==", "cpu": [ "x64" ], @@ -5676,8 +5680,8 @@ } }, "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { - "version": "0.142.0", - "integrity": "sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==", + "version": "0.150.0", + "integrity": "sha512-A/hycCFjLUrCmLoL5O/vBp40ohlaXO1Ta3v5hqYZxicYFs129wZmgZJggcW4mYGYbZebQLhup+N8JjeQl8qwyw==", "cpu": [ "arm" ], @@ -5692,8 +5696,8 @@ } }, "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { - "version": "0.142.0", - "integrity": "sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==", + "version": "0.150.0", + "integrity": "sha512-pbqahg1Pkz7J4RKmXm30s/iQu99hv64ayXCu8P4p75HcEH5azOdR4eGqiB6lFGkFR3mMpzaYsSKLkS8oJbjmeQ==", "cpu": [ "arm" ], @@ -5708,8 +5712,8 @@ } }, "node_modules/@oxc-parser/binding-linux-arm64-gnu": { - "version": "0.142.0", - "integrity": "sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==", + "version": "0.150.0", + "integrity": "sha512-HV11aRbBQGwqv8bEo+K/6qr88uB4fEe1mhXncMhlVCrj+WpBSraxrUzHaPVmeQRU6x6x8v/3DuCbbo93rpp+fw==", "cpu": [ "arm64" ], @@ -5724,8 +5728,8 @@ } }, "node_modules/@oxc-parser/binding-linux-arm64-musl": { - "version": "0.142.0", - "integrity": "sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==", + "version": "0.150.0", + "integrity": "sha512-k6pVkJqALwtEuP4zukVhGRhdIy4+ofChUIUUsAHHyqDZlNsknmel3JjbggrJiWGaes6h/dIcWmEXsKW4SmK9oQ==", "cpu": [ "arm64" ], @@ -5740,8 +5744,8 @@ } }, "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { - "version": "0.142.0", - "integrity": "sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==", + "version": "0.150.0", + "integrity": "sha512-tEFg39mw/rHO5n8GK2DR4ZMFfsPQZtnQIPbsIpjoQRomJc/2tRfsCSmCT6gNit+NZGoeJG5aH9ATDH5bf0WnoQ==", "cpu": [ "ppc64" ], @@ -5756,8 +5760,8 @@ } }, "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { - "version": "0.142.0", - "integrity": "sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==", + "version": "0.150.0", + "integrity": "sha512-0JO7IFkoek6HqV589Menx3hJySNXi6quRhO4tq2P7kpEHKEXoDATnoPVUpn5B7gDH2KLkdo751N0uIrGJFCTCw==", "cpu": [ "riscv64" ], @@ -5772,8 +5776,8 @@ } }, "node_modules/@oxc-parser/binding-linux-riscv64-musl": { - "version": "0.142.0", - "integrity": "sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==", + "version": "0.150.0", + "integrity": "sha512-7XPzREnyAS5wHU9aB+49Uaqgoo1gVCklHc3DRlc3fJy2tXD/eAJFN1QFz/crj+Ulup5P1+axNREF+/RGaB/IRA==", "cpu": [ "riscv64" ], @@ -5788,8 +5792,8 @@ } }, "node_modules/@oxc-parser/binding-linux-s390x-gnu": { - "version": "0.142.0", - "integrity": "sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==", + "version": "0.150.0", + "integrity": "sha512-7n7ZxcbRWDFaTdyj8M8p0O9OfzoMKm8O6syBAIgcSutbOALq0Bb9G52Me+XH0anMLZV+NgXc726gqgG2q39vcQ==", "cpu": [ "s390x" ], @@ -5804,8 +5808,8 @@ } }, "node_modules/@oxc-parser/binding-linux-x64-gnu": { - "version": "0.142.0", - "integrity": "sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==", + "version": "0.150.0", + "integrity": "sha512-Vx0GSA9ZCRTUiczoEQnIIyXkMvtEY8uc6IiwLQtMe5ci2sXPqjrry0Ek5fsPP1k2kCzkML3ow9UOOEgnEDi7Bw==", "cpu": [ "x64" ], @@ -5820,8 +5824,8 @@ } }, "node_modules/@oxc-parser/binding-linux-x64-musl": { - "version": "0.142.0", - "integrity": "sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==", + "version": "0.150.0", + "integrity": "sha512-ii4/9m3viDLssMnfXU7/Pni/3nYplApuGayceX4qsVGaqqQJCx3tHIH9M/HWIeSty5i8LjS21zPYT6lVIkwLxw==", "cpu": [ "x64" ], @@ -5836,8 +5840,8 @@ } }, "node_modules/@oxc-parser/binding-openharmony-arm64": { - "version": "0.142.0", - "integrity": "sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==", + "version": "0.150.0", + "integrity": "sha512-ZtoxX5rez36ZWkwtiEhjkCPnlTPoQ2I7lIL0IYZ1yjxMYohbvoOjBmUIZzrf9W/HouONq0omIfTeCWEY+R380w==", "cpu": [ "arm64" ], @@ -5851,27 +5855,9 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxc-parser/binding-wasm32-wasi": { - "version": "0.142.0", - "integrity": "sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.2", - "@emnapi/runtime": "1.11.2", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@oxc-parser/binding-win32-arm64-msvc": { - "version": "0.142.0", - "integrity": "sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==", + "version": "0.150.0", + "integrity": "sha512-VqeRb5JX/bKYBrff7TUDvJyKY0914UzuCkuXIGxJS4FUmvMNfqkCbc7Sq90iMz9DlmAtlggwaBYQ9eg3h3ltiw==", "cpu": [ "arm64" ], @@ -5886,8 +5872,8 @@ } }, "node_modules/@oxc-parser/binding-win32-ia32-msvc": { - "version": "0.142.0", - "integrity": "sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==", + "version": "0.150.0", + "integrity": "sha512-EPqJfeZ4Pgg2BJSsCV8GozxV0FDltRzpVtGVa1r2noJu1iu+opOw3gtBWXwIo9odCT/MdFfyHxdy0/CRqs3OqA==", "cpu": [ "ia32" ], @@ -5902,8 +5888,8 @@ } }, "node_modules/@oxc-parser/binding-win32-x64-msvc": { - "version": "0.142.0", - "integrity": "sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==", + "version": "0.150.0", + "integrity": "sha512-n5YMzbqwPQqozbkrexi0lNZrUz5cnPHalYpFWe6Dau7AmKIWNo+y24IwzDuZEQtEdUHuKhXmMMih/MPjOI+CMw==", "cpu": [ "x64" ], @@ -5918,12 +5904,12 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.142.0", - "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "version": "0.150.0", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", "dev": true, "license": "MIT", "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" } }, "node_modules/@parcel/watcher": { @@ -6201,15 +6187,15 @@ } }, "node_modules/@peculiar/asn1-cms": { - "version": "2.9.4", - "integrity": "sha512-cben7oxmQsUGZqotus7yt0srYdncOT6RNWcTQ77T2RFOXejYVYkXadrfePdRcrVpO9K95IRLKKglG2k38jKXuw==", + "version": "2.10.0", + "integrity": "sha512-CkX0H4NCIOMHOU3rh2xXZywinYZ/EnJlaOlJiGI0e5L4XjFqHf4iH30LMbXWChVppdA9ljbanjtzQFOhDNfTWg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@peculiar/asn1-schema": "^2.9.4", - "@peculiar/asn1-x509": "^2.9.4", - "@peculiar/asn1-x509-attr": "^2.9.4", + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "@peculiar/asn1-x509-attr": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" }, @@ -6218,14 +6204,14 @@ } }, "node_modules/@peculiar/asn1-csr": { - "version": "2.9.4", - "integrity": "sha512-xd4YN4vpRjkDAQWVfZZkeu12IEND7DOpkqaHSIHxZl1uggUNa9Ju0QxY2jHvDAS9pP0zhRBytg8ifsnGo3V0jw==", + "version": "2.10.0", + "integrity": "sha512-jTPTr/9rxKM+niQLMF3jiAMb0lWHUhUHIuSOhdGCIfpB4FAQYf9SoiXOgxP8bS/68IB+bj+1OHvVnDVCQfVUZw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@peculiar/asn1-schema": "^2.9.4", - "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" }, @@ -6234,14 +6220,14 @@ } }, "node_modules/@peculiar/asn1-ecc": { - "version": "2.9.4", - "integrity": "sha512-JJXefFshRAuVAjWQo/39bkg1ywc1VaiO44S8RRC+Ykvf/u2KDmYffoDb0ZBPCR5uJy4AGKQhl8mX+Q8ShcWaXQ==", + "version": "2.10.0", + "integrity": "sha512-GFd3iOjFrWX+QWH2R2dO5QSJyyRGv9CIBKtRlGlPNCvb2RvmCbBDexe91MJgV76c69d998Xsa+CvuQ98seoiPg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@peculiar/asn1-schema": "^2.9.4", - "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" }, @@ -6250,16 +6236,16 @@ } }, "node_modules/@peculiar/asn1-pfx": { - "version": "2.9.4", - "integrity": "sha512-khuGzHTzNzk4GDlIBEILyIs6Lce0yn0ZBdoI9v93kmNncfZRhD+AQ5ODFqdhvoE8cMJF/JMTQ8yA+t1D14kqCw==", + "version": "2.10.0", + "integrity": "sha512-1y3QK9ZH1IPleAMmRoJlPS10eGkAWnULETW8zDFNUK1ffqpG7zmDY+kYBMYQgwlcQZ2iitLm2z2EBP0xzRXpaA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@peculiar/asn1-cms": "^2.9.4", - "@peculiar/asn1-pkcs8": "^2.9.4", - "@peculiar/asn1-rsa": "^2.9.4", - "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-cms": "^2.10.0", + "@peculiar/asn1-pkcs8": "^2.10.0", + "@peculiar/asn1-rsa": "^2.10.0", + "@peculiar/asn1-schema": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" }, @@ -6268,14 +6254,14 @@ } }, "node_modules/@peculiar/asn1-pkcs8": { - "version": "2.9.4", - "integrity": "sha512-duRdotlUx9eDZe6QrQpQKl61RbWykCHBCkKayP8V8XdEFwlKHZ8qGGDMyS6Pye7OX7nLFttTTpRkJeet78ckwQ==", + "version": "2.10.0", + "integrity": "sha512-Ri+BZT9bnwqlHWmhs7lvWjvJRxKev2hA2+4EbZajgeYWCbR8hyv0znF4DhsFouOhMj2nSDV/iGJ6DMQtwITnCw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@peculiar/asn1-schema": "^2.9.4", - "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" }, @@ -6284,18 +6270,18 @@ } }, "node_modules/@peculiar/asn1-pkcs9": { - "version": "2.9.4", - "integrity": "sha512-kaL4cNxBpdQE2dKlyZBqz4ygCrwffO+8wfoxTEqM1Z8RadvCeELBRzcv0dzM8aY9azHMwODO5nxU65zXmhToOQ==", + "version": "2.10.0", + "integrity": "sha512-XIXsbDQFezYk6fudczkuvawkRD4GNpISGCqfYhdkJlvcGF/RFknU+0DDJagOaJqBf+PdPzk4J9oMqDGcvfR9HQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@peculiar/asn1-cms": "^2.9.4", - "@peculiar/asn1-pfx": "^2.9.4", - "@peculiar/asn1-pkcs8": "^2.9.4", - "@peculiar/asn1-schema": "^2.9.4", - "@peculiar/asn1-x509": "^2.9.4", - "@peculiar/asn1-x509-attr": "^2.9.4", + "@peculiar/asn1-cms": "^2.10.0", + "@peculiar/asn1-pfx": "^2.10.0", + "@peculiar/asn1-pkcs8": "^2.10.0", + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", + "@peculiar/asn1-x509-attr": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" }, @@ -6304,14 +6290,14 @@ } }, "node_modules/@peculiar/asn1-rsa": { - "version": "2.9.4", - "integrity": "sha512-pZ96eD1PptovcWQ/GSmuNFXd/7EQJNlKfDaNCyE2rx3W0v6QFelkzquVqRSRyyDXXCYD69ZXJDzZ8GhIiQzKoA==", + "version": "2.10.0", + "integrity": "sha512-4Jvmwlh3gZAhNZ4/u7JSyLuce9hofOFZ4o3PYKAccCImGnyaT5CMym/ATgvAvqpOYFNW531oPZvJlEBhJy9VfA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@peculiar/asn1-schema": "^2.9.4", - "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" }, @@ -6320,8 +6306,8 @@ } }, "node_modules/@peculiar/asn1-schema": { - "version": "2.9.4", - "integrity": "sha512-GjzePcT9Iw8NzeOPf73iNS9xM+TBhd/FilAfP+RQGkTMQJTVWtytN3JHJACCjf/ABNau5S7mS3g+DcuxmRgYEg==", + "version": "2.10.0", + "integrity": "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw==", "dev": true, "license": "MIT", "peer": true, @@ -6335,13 +6321,13 @@ } }, "node_modules/@peculiar/asn1-x509": { - "version": "2.9.4", - "integrity": "sha512-CxhBo/RdEbMMob7T31ZdQjGuoyRFLVwrDzTn25bihzBasRg9kRm/0IxIPvhgQtcK/9dNcO1XQL2fuPugwELL0Q==", + "version": "2.10.0", + "integrity": "sha512-ucNVg8+ANveTpMN3fy9lA2alryONdXc2A4cEG2hMniWbvQt+YOZoe8BI80YYNO8FBcuDY1qbDQ4uQGQVraHxGA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@peculiar/asn1-schema": "^2.9.4", + "@peculiar/asn1-schema": "^2.10.0", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" @@ -6351,14 +6337,14 @@ } }, "node_modules/@peculiar/asn1-x509-attr": { - "version": "2.9.4", - "integrity": "sha512-ehQXbpQaQYycgu8OrvigwSPTFfVRcu0ECNYCWw+yzBp02Lw5paRqzzhUpfOgO2K38+WfFZuEz/0RPtam5g0OMg==", + "version": "2.10.0", + "integrity": "sha512-/85GtKOKmgvuSJNlaFfwGWNdRSZZ+hpF02NyM01XiCdzpaZONiBltDyfluFPvFx966CR+ZHNSG1jniwpy07oGg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@peculiar/asn1-schema": "^2.9.4", - "@peculiar/asn1-x509": "^2.9.4", + "@peculiar/asn1-schema": "^2.10.0", + "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" }, @@ -6436,9 +6422,25 @@ "node": ">=20" } }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.0", - "integrity": "sha512-9yB1l95IrJuNGDFdOYe79vdApdz6WWBCObE+rQ2LUliYUlcyFwSYIb2xb5/Ifw7dAtMy2ZqNyd8QTSOc7duAKw==", + "version": "1.2.8", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", "cpu": [ "arm64" ], @@ -6453,8 +6455,8 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.0", - "integrity": "sha512-pexNaW9ACLUOaBITOpU6qVu4VrsOFIjTv6bzgu0YUATo4eUJx0V605PxwZfndpPOn0ilqGqvGQ0M8UW0IE24jg==", + "version": "1.2.8", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", "cpu": [ "arm64" ], @@ -6469,8 +6471,8 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.0", - "integrity": "sha512-NqKYaq0355ZmNMG4QGpxtEDxsc7tGDhjhCm4PpE0cwnBW+5Il95LJyq414niEiaKLVjnVHBEjSo1wngKxJNiFw==", + "version": "1.2.8", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", "cpu": [ "x64" ], @@ -6485,8 +6487,8 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.0", - "integrity": "sha512-3vPoHzh6eBTz9IbB0/qZdSr0Qeks2echn+I4cHu2joV74VriPDdldswksEDzrl1mBB+oPRi+67+3Ib59paxIPQ==", + "version": "1.2.8", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", "cpu": [ "x64" ], @@ -6501,8 +6503,8 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.0", - "integrity": "sha512-E6NNefZ1bUVmKJq2tJkf45J4Zyczj7qm9rUT7NY+Xo2474Y13qWAwc2tvBt0BAVbmtXR1llkxXg0Ou1jbDf2SQ==", + "version": "1.2.8", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", "cpu": [ "arm" ], @@ -6517,8 +6519,8 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.0", - "integrity": "sha512-D+TgkdgM1vu+7/Fpf8+v0ARW+RXEP9Ccazgm8zQ4JFFd9Q7SrYQ2TakU5S5ihazQDgpKyAgZDOcIFsvoHmTZ8w==", + "version": "1.2.8", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", "cpu": [ "arm64" ], @@ -6533,8 +6535,8 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.0", - "integrity": "sha512-wUqdwJBbAv0APN87GecstdMUtLjjNTs0hBALpxETD73mccFxdmt/XeizXDtN5RAlBwNKmI+Tg+blect2G+8IeQ==", + "version": "1.2.8", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", "cpu": [ "arm64" ], @@ -6549,8 +6551,8 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.0", - "integrity": "sha512-9DtF35qR9/NrfhM4oxLplCzVVjE+KKm8Pjemi0i/sdhAWkUasjmSo8WTTubNJClhSHCfyk2yeyoXDQEDPtDAAw==", + "version": "1.2.8", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", "cpu": [ "ppc64" ], @@ -6565,8 +6567,8 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.0", - "integrity": "sha512-RzuHrBh8X8Hntd2N4VR02QGEciq/9JhcZoTpR/Cee6otRrlILGCf3cg2ygHuih+ZebUnWmMrDX6ITI85btO6rQ==", + "version": "1.2.8", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", "cpu": [ "s390x" ], @@ -6581,8 +6583,8 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.0", - "integrity": "sha512-MK7L0018jjh1jR3mh21G2j1zAVcpscJBlPo2z19pRjv2XOYGRhaV4LyiD8HO6nCDdZln9IFgCMIV5yt4E3klGQ==", + "version": "1.2.8", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", "cpu": [ "x64" ], @@ -6597,8 +6599,8 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.0", - "integrity": "sha512-gyrxLQ9NfGb/9LoVnC4kb9miUghw1mghnkfYvNHSnVIXriabnfgGPUP4RLcJm87q3KgYz4FYUG8IDiWUT+CpSw==", + "version": "1.2.8", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", "cpu": [ "x64" ], @@ -6613,8 +6615,8 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.0", - "integrity": "sha512-/6VFMQGRmrhP77KXDC+StIxGzcNp5JOIyYtw0CQ8gPlzhpiIRucYfoM5FaFamHd5BJYIdH86yfP46l1p3WdrFA==", + "version": "1.2.8", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", "cpu": [ "arm64" ], @@ -6628,27 +6630,9 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.2.0", - "integrity": "sha512-rwdbUL465kisF24WEJLvP3JrEG6E5GRuIHt5wpMwHGERtHe4Wm2CIvtf5gTBgr2tGOHKh5NdKEAFS2VkOPE91g==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.2", - "@emnapi/runtime": "1.11.2", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.0", - "integrity": "sha512-+5suHwRiKGmhwyUaNT8a5QbrBvLFh2DbO910TEmGRH1aSxwrCezodvGQnulv4uiWEIv1Kq4ypRsJ5+O+ry1DiA==", + "version": "1.2.8", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", "cpu": [ "arm64" ], @@ -6663,8 +6647,8 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.0", - "integrity": "sha512-WfFv6/qGufotqBSBzBYwgpCkJBk8Nj7697LL9vTz/XWc67e0r3oewu8iMRwQj3AUL45GVD7wVsPjCsAAtW66Wg==", + "version": "1.2.8", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", "cpu": [ "x64" ], @@ -6684,548 +6668,324 @@ "dev": true, "license": "MIT" }, - "node_modules/@rollup/plugin-json": { - "version": "6.1.0", - "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", - "dev": true, + "node_modules/@rrweb/record": { + "version": "2.0.0-alpha.20", + "integrity": "sha512-marVOU3Lc285mwLCp+vI6M/eJ+wZ6lcTa7fX0zcdmBsM+j5loXmMQjkmIvkji5+lAbkq5/w2cwto3GS0TaCjtg==", "license": "MIT", "dependencies": { - "@rollup/pluginutils": "^5.1.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "@rrweb/types": "^2.0.0-alpha.20", + "@rrweb/utils": "^2.0.0-alpha.20", + "rrweb": "^2.0.0-alpha.20" } }, - "node_modules/@rollup/pluginutils": { - "version": "5.4.0", - "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", - "dev": true, + "node_modules/@rrweb/record/node_modules/rrdom": { + "version": "2.1.6", + "integrity": "sha512-EMRrTs1GRsmb8bNyAs107wG7eRwVEOvDUBsnCyvQnVV6elrmrua/AeS0tdcA0gLW4eptr4l6bRsS/2QRICvdqQ==", "license": "MIT", "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^2.0.2", - "picomatch": "^4.0.2" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } + "rrweb-snapshot": "^2.1.6" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.63.4", - "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==", - "cpu": [ - "arm" - ], - "dev": true, + "node_modules/@rrweb/record/node_modules/rrweb": { + "version": "2.1.6", + "integrity": "sha512-OMdw4lWyfCx2fNhKCv1eZe54oVmD8r4QZsCXKY9fdt+cD/eiiupFgMHoFuwbwvltr4JH3IwWKXoadVjiMNhOZQ==", "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "dependencies": { + "@rrweb/types": "^2.1.6", + "@rrweb/utils": "^2.1.6", + "@types/css-font-loading-module": "0.0.7", + "@xstate/fsm": "^1.4.0", + "base64-arraybuffer": "^1.0.1", + "mitt": "^3.0.0", + "rrdom": "^2.1.6", + "rrweb-snapshot": "^2.1.6" + } }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.63.4", - "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==", - "cpu": [ - "arm64" - ], + "node_modules/@rrweb/types": { + "version": "2.1.6", + "integrity": "sha512-DacFzGpPbQ91q3rRyh/AeZrtFYYonmLT+qgGlmh2iGcW2HPnrABJNgJWhpyzYwp/xed6Fbpi8yMxhRBlrOY0ZA==", + "license": "MIT" + }, + "node_modules/@rrweb/utils": { + "version": "2.1.6", + "integrity": "sha512-BIRXcxiytJs5Guxw0tzsnVS7ukcMqtnxixjIGvV49VdJeOEG6NJeMU7o8efbHW1D52a12Y8QsgDjgDgblg3org==", + "license": "MIT" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.63.4", - "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==", - "cpu": [ - "arm64" - ], + "node_modules/@schematics/angular": { + "version": "22.2.0", + "integrity": "sha512-IOD+T+S5LyVVuLAtLq/5k+WxnT9UpJecyDPvIT+AWug+mZJkd+HKVVHryenNJBaW29tIxNfoDuVFPEsZ4UKcNw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "dependencies": { + "@angular-devkit/core": "22.2.0", + "@angular-devkit/schematics": "22.2.0", + "jsonc-parser": "3.3.1", + "oxc-parser": "0.150.0", + "typescript": "6.0.3" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.63.4", - "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==", - "cpu": [ - "x64" - ], + "node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.63.4", - "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==", - "cpu": [ - "arm64" - ], + "node_modules/@shikijs/langs": { + "version": "3.23.0", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "dependencies": { + "@shikijs/types": "3.23.0" + } }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.63.4", - "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==", - "cpu": [ - "x64" - ], + "node_modules/@shikijs/themes": { + "version": "3.23.0", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] + "dependencies": { + "@shikijs/types": "3.23.0" + } }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.63.4", - "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==", - "cpu": [ - "arm" - ], + "node_modules/@shikijs/types": { + "version": "3.23.0", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.63.4", - "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==", - "cpu": [ - "arm" - ], + "node_modules/@shikijs/vscode-textmate": { + "version": "10.0.2", + "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.63.4", - "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==", - "cpu": [ - "arm64" - ], + "node_modules/@sinclair/typebox": { + "version": "0.34.52", + "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "MIT" }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.63.4", - "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==", - "cpu": [ - "arm64" - ], + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.63.4", - "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==", - "cpu": [ - "loong64" - ], + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.63.4", - "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "node_modules/@smithy/core": { + "version": "3.35.0", + "integrity": "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.63.4", - "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.63.4", - "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.63.4", - "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "node_modules/@smithy/protocol-http": { + "version": "5.6.2", + "integrity": "sha512-Asd04MaxODN6FNY8EPTeCAM4kPNi3jDUAjZU0Y4F9rHvpLUrrUo7KLcxFgSthywFr6dZfIyDLIJda6jxmVTk5w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.63.4", - "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "node_modules/@smithy/querystring-builder": { + "version": "4.5.2", + "integrity": "sha512-TLuVW4cGFCj+LirJylCVcFwxcUCpZ5Av0I7K9k/xhm6v6zAUeXSNuZ9e2QKuxcwkgrejL8LZvQTJy42pmq5bOg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.63.4", - "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "node_modules/@smithy/signature-v4": { + "version": "5.7.4", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.63.4", - "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "node_modules/@smithy/types": { + "version": "4.19.0", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.63.4", - "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.63.4", - "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] + "node_modules/@smithy/util-hex-encoding": { + "version": "3.0.0", + "integrity": "sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.63.4", - "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.63.4", - "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==", - "cpu": [ - "arm64" - ], + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.4", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "tslib": "^2.4.0" + } }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.63.4", - "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==", - "cpu": [ - "ia32" - ], + "node_modules/@types/babel__core": { + "version": "7.20.5", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.63.4", - "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==", - "cpu": [ - "x64" - ], + "node_modules/@types/babel__core/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": ">=6.9.0" + } }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.63.4", - "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==", - "cpu": [ - "x64" - ], + "node_modules/@types/babel__core/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": ">=6.9.0" + } }, - "node_modules/@rollup/wasm-node": { - "version": "4.63.4", - "integrity": "sha512-pXmzGQzpMazI9wL+5ifqPfIOR5FFblTWTPS/tIg/WXH8JpLYFbJsOq4UyL2RH2FNAnfPqoC28wgRLudGH66NPA==", + "node_modules/@types/babel__core/node_modules/@babel/parser": { + "version": "7.29.9", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.9" + "@babel/types": "^7.29.8" }, "bin": { - "rollup": "dist/bin/rollup" + "parser": "bin/babel-parser.js" }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "fsevents": "~2.3.2" + "node": ">=6.0.0" } }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@schematics/angular": { - "version": "22.1.8", - "integrity": "sha512-V37T9uHOQVHyxxOqwcJ9xjSIW/mW9UuSfjOc7WJE4V8+3zj0abDJLHuoxDZKe0icYGapgOcvHyYjtNOjSeSivw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "22.1.8", - "@angular-devkit/schematics": "22.1.8", - "jsonc-parser": "3.3.1", - "typescript": "6.0.3" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "3.23.0", - "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0", - "@shikijs/vscode-textmate": "^10.0.2" - } - }, - "node_modules/@shikijs/langs": { - "version": "3.23.0", - "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0" - } - }, - "node_modules/@shikijs/themes": { - "version": "3.23.0", - "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "3.23.0" - } - }, - "node_modules/@shikijs/types": { - "version": "3.23.0", - "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinclair/typebox": { - "version": "0.34.52", - "integrity": "sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "15.4.0", - "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.4", - "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__core/node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@types/babel__core/node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@types/babel__core/node_modules/@babel/parser": { - "version": "7.29.9", - "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@types/babel__core/node_modules/@babel/types": { - "version": "7.29.8", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "node_modules/@types/babel__core/node_modules/@babel/types": { + "version": "7.29.8", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -7414,28 +7174,33 @@ "@types/node": "*" } }, + "node_modules/@types/css-font-loading-module": { + "version": "0.0.7", + "integrity": "sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/express": { - "version": "4.17.25", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "version": "5.0.6", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "^1" + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" } }, "node_modules/@types/express-serve-static-core": { - "version": "4.19.9", - "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "version": "5.1.3", + "integrity": "sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw==", "dev": true, "license": "MIT", "peer": true, @@ -7468,16 +7233,6 @@ "license": "MIT", "peer": true }, - "node_modules/@types/http-proxy": { - "version": "1.17.17", - "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", @@ -7562,13 +7317,6 @@ "@types/lodash": "*" } }, - "node_modules/@types/mime": { - "version": "1.3.5", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@types/node": { "version": "22.20.4", "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", @@ -7592,13 +7340,6 @@ "license": "MIT", "peer": true }, - "node_modules/@types/retry": { - "version": "0.12.2", - "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@types/send": { "version": "1.2.1", "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", @@ -7620,35 +7361,13 @@ } }, "node_modules/@types/serve-static": { - "version": "1.15.10", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "version": "2.2.0", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/sockjs": { - "version": "0.3.36", - "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { "@types/node": "*" } }, @@ -7671,8 +7390,8 @@ "license": "MIT" }, "node_modules/@types/ws": { - "version": "8.18.1", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "version": "8.18.2", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", "dev": true, "license": "MIT", "peer": true, @@ -7696,16 +7415,16 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.70.0", - "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "version": "8.71.0", + "integrity": "sha512-pqcS9c1HxZTHt7End4nXqd0s5lJrrFzrgCkKFJrsbUnaL6M3+6oBFZaslg6Gjsl3argl2DDRFROnXARaZ2e4Nw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/type-utils": "8.70.0", - "@typescript-eslint/utils": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/type-utils": "8.71.0", + "@typescript-eslint/utils": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -7718,21 +7437,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.70.0", + "@typescript-eslint/parser": "^8.71.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.70.0", - "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "version": "8.71.0", + "integrity": "sha512-CG4nPk1f2zc8yw4pALqHsFYH2hdo+h1T9daSp21+Hnxi9LOE3GT9hAfTKJCBXVNM2GmYs1eMEP615wPoeOgk3A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "debug": "^4.4.3" }, "engines": { @@ -7748,13 +7467,13 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.70.0", - "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "version": "8.71.0", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.70.0", - "@typescript-eslint/types": "^8.70.0", + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", "debug": "^4.4.3" }, "engines": { @@ -7769,13 +7488,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.70.0", - "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "version": "8.71.0", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0" + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7786,8 +7505,8 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.70.0", - "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "version": "8.71.0", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", "dev": true, "license": "MIT", "engines": { @@ -7802,14 +7521,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.70.0", - "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "version": "8.71.0", + "integrity": "sha512-i8uO1qbdxeKgRnS5sCRt6On3/nfo2d2DwQe3Yvjx543zLy7r8ySqRuPPiIIXAhS03U0v5NfAFx+rUgxFzKKwNw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0", + "@typescript-eslint/utils": "8.71.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -7826,8 +7545,8 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.70.0", - "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "version": "8.71.0", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", "dev": true, "license": "MIT", "engines": { @@ -7839,15 +7558,15 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.70.0", - "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "version": "8.71.0", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.70.0", - "@typescript-eslint/tsconfig-utils": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -7866,15 +7585,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.70.0", - "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "version": "8.71.0", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0" + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7889,12 +7608,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.70.0", - "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "version": "8.71.0", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/types": "8.71.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -8175,37 +7894,6 @@ "node": ">=14.0.0" } }, - "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.10.0", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { "version": "1.12.2", "integrity": "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==", @@ -8418,6 +8106,11 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@xstate/fsm": { + "version": "1.6.5", + "integrity": "sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==", + "license": "MIT" + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", @@ -8432,1072 +8125,616 @@ "license": "Apache-2.0", "peer": true }, - "node_modules/accepts": { - "version": "2.0.0", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "node_modules/@yuku-codegen/binding-android-arm64": { + "version": "0.10.2", + "integrity": "sha512-Oc2KInVkPfUjEB4PeV6X0NvIoyYTzDe/WmqFrTwBqKs6YKnz4A7XwWQvJTb7M0rLz9a6WgpUUiiS5QKaCi4O/g==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } + "optional": true, + "os": [ + "android" + ] }, - "node_modules/acorn": { - "version": "8.18.0", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "node_modules/@yuku-codegen/binding-darwin-arm64": { + "version": "0.10.2", + "integrity": "sha512-3H7eNPIHJndUJXZ4QUGBPq1zC6RGcUZfm7bymJes+/N7wTVWUn1bxZ9JcozYHpkWQNm9f23G8YWbaHD9aYH72A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "node_modules/@yuku-codegen/binding-darwin-x64": { + "version": "0.10.2", + "integrity": "sha512-AJOUpR2s3LF9AWiiub/Uyc/UoXNTWcq8hK3cVI6fUSXtwh34dG21J6hRuNlO6JVJFTo4o3ScVvZOrh/YFfUEAA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/adjust-sourcemap-loader": { - "version": "4.0.0", - "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "loader-utils": "^2.0.0", - "regex-parser": "^2.2.11" - }, - "engines": { - "node": ">=8.9" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/adjust-sourcemap-loader/node_modules/loader-utils": { - "version": "2.0.4", - "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "node_modules/@yuku-codegen/binding-freebsd-x64": { + "version": "0.10.2", + "integrity": "sha512-ms7DcZu87u5CiK/wMwvpKAvAmtaqKjCQIXNweMLypG6qbuiaOMQf1jpbB5+j46xBcgCovo8TQMcv0bCQLKIL9w==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - }, - "engines": { - "node": ">=8.9.0" - } + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/agent-base": { - "version": "9.0.0", - "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "node_modules/@yuku-codegen/binding-linux-arm-gnu": { + "version": "0.10.2", + "integrity": "sha512-xJDpYAsKV5+eaiBhTYl05fvT6sst4PsCeuIFMRu9b0/WCSrNQPfCDtAQ5/MS6xNpsdujdZEPR+gmfWk3ZG5i3A==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">= 20" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/ajv": { - "version": "8.20.0", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "node_modules/@yuku-codegen/binding-linux-arm-musl": { + "version": "0.10.2", + "integrity": "sha512-qfTkgd7AEx61l4K9VtXWCGTxc/fmXJ4ZheDz3i+/AAJUtg4sa0bPrc0VBxPggB/rayR8Z3+JfrBItuyanBJJ9Q==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "node_modules/@yuku-codegen/binding-linux-arm64-gnu": { + "version": "0.10.2", + "integrity": "sha512-4e6Mifm/4UdjtU3D8mATIrvgP+xEiH8xtQOjd0zpd72XKWT7ug3sdyhq2utkkZFP6BvYp7SgZrI+aR4VeIOHdw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/ajv-keywords": { - "version": "5.1.0", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "node_modules/@yuku-codegen/binding-linux-arm64-musl": { + "version": "0.10.2", + "integrity": "sha512-RdsJrUfDYFVV3JOmWFUWwSu31fa1APn12xDeIKxgl/YWxrabwtxsDBNjF2561c7tmcbiewdCUvngqRs8AyGVLA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/ansi-colors": { - "version": "4.1.3", - "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "node_modules/@yuku-codegen/binding-linux-x64-gnu": { + "version": "0.10.2", + "integrity": "sha512-mVzWimEPPreaPyXIUvxsHoJzvO7ckZ5j0LgQFHz04zQIRwt9A9T2hA7K5/jYjJKkMxWG/U2VjhGNwVhtyU+uqg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "node_modules/@yuku-codegen/binding-linux-x64-musl": { + "version": "0.10.2", + "integrity": "sha512-Y77fyISurmr2mvO1yeq3u+x/WJbACgPR4w+lzEVx30ViCxZzIGrZPRN1yEdZ9xUNPNsIO5thBHIdRNG+UGUG+Q==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "0.21.3", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/ansi-html-community": { - "version": "0.0.8", - "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", - "dev": true, - "engines": [ - "node >= 0.8.0" + "node_modules/@yuku-codegen/binding-win32-arm64": { + "version": "0.10.2", + "integrity": "sha512-1+tGLyG0u5YYy0lev4ck7/0sd8xJ9SZde0dLut7qLDPZV5WVTV8x6OxNXUne/PGuHZyO1vK+txPQzzHOyXHGSw==", + "cpu": [ + "arm64" ], - "license": "Apache-2.0", - "peer": true, - "bin": { - "ansi-html": "bin/ansi-html" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@yuku-codegen/binding-win32-x64": { + "version": "0.10.2", + "integrity": "sha512-8a2SExRohRbwC0MY4VpOF0/RSckrJ2ASZqAor5/RYSJJaeadR93n10gZzcKT6pY9ukiSOIZCRKVRD2tH73T0qA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/anymatch": { - "version": "3.1.3", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "node_modules/@yuku-parser/binding-android-arm64": { + "version": "0.10.2", + "integrity": "sha512-2VPBU9fRGRAQ2xPAvghnec3oou5Nrxm2Bezhf+13UMspAxQSkF/32r+ySKXSwIPA5/RivumDJDxAxc301Sgicw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } + "license": "MIT", + "optional": true, + "os": [ + "android" + ] }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "node_modules/@yuku-parser/binding-darwin-arm64": { + "version": "0.10.2", + "integrity": "sha512-LD+PMZE51tCYTOss5HBkm3/AE39MvcMDBfWJx7A4yDcjfNbAQDnHZKtzSOuqrswx+TQHY0ws5xn6+fWOwtmfBA==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/argparse": { - "version": "2.0.1", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "node_modules/@yuku-parser/binding-darwin-x64": { + "version": "0.10.2", + "integrity": "sha512-mmZ8cND+AoIIMRERyMinlg5ApHxP23B9jH2B5wT7T+dliPa9rubLxneB/SUjFwyUjGaFnB5G7t4YvpfbO5zbkQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "Python-2.0" + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "node_modules/@yuku-parser/binding-freebsd-x64": { + "version": "0.10.2", + "integrity": "sha512-gVIjaaIddbRfAhHlC8N809wQWml7mxfSVnzaLtzGXObTeFTPAg/YVnQXt1UOhPM1ah5eG/8Y0RUlNpB4GVe7eQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/array-includes": { - "version": "3.2.0", - "integrity": "sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.2", - "es-object-atoms": "^1.1.2", - "es-shim-unscopables": "^1.1.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.6", - "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-shim-unscopables": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/asn1js": { - "version": "3.0.10", - "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "pvtsutils": "^1.3.6", - "pvutils": "^1.1.5", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/async-function": { - "version": "1.0.0", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/autoprefixer": { - "version": "10.5.4", - "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/autoprefixer" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "browserslist": "^4.28.6", - "caniuse-lite": "^1.0.30001806", - "fraction.js": "^5.3.4", - "picocolors": "^1.1.1", - "postcss-value-parser": "^4.2.0" - }, - "bin": { - "autoprefixer": "bin/autoprefixer" - }, - "engines": { - "node": "^10 || ^12 || >=14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/axe-core": { - "version": "4.13.0", - "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", - "dev": true, - "license": "MPL-2.0", - "engines": { - "node": ">=4" - } - }, - "node_modules/babel-jest": { - "version": "30.5.2", - "integrity": "sha512-ES8WeJ2fNWPEDunyAtUfJ12nHyaWUloCHdkfK2oW5uSoQSUmjNKdWiGaXJITJwvFEWoD/evHEg/QoCXAvLaQqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/transform": "30.5.2", - "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^8.0.0", - "babel-preset-jest": "30.5.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-0" - } - }, - "node_modules/babel-loader": { - "version": "10.1.1", - "integrity": "sha512-JwKSzk2kjIe7mgPK+/lyZ2QAaJcpahNAdM+hgR2HI8D0OJVkdj8Rl6J3kaLYki9pwF7P2iWnD8qVv80Lq1ABtg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "find-up": "^5.0.0" - }, - "engines": { - "node": "^18.20.0 || ^20.10.0 || >=22.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0 || ^8.0.0-beta.1", - "@rspack/core": "^1.0.0 || ^2.0.0-0", - "webpack": ">=5.61.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } - } + "optional": true, + "os": [ + "freebsd" + ] }, - "node_modules/babel-plugin-istanbul": { - "version": "8.0.0", - "integrity": "sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==", - "dev": true, - "license": "BSD-3-Clause", - "workspaces": [ - "test/babel-8" + "node_modules/@yuku-parser/binding-linux-arm-gnu": { + "version": "0.10.2", + "integrity": "sha512-q/XPPQQAPdlw05aPj30ygBhekmQryGOwxVraBgApjKK8yY1kNQgqq6XCYLF1WHSee+lhxclrTO7W0/bt7dR1GA==", + "cpu": [ + "arm" ], - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-instrument": "^6.0.2", - "test-exclude": "^7.0.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "30.5.0", - "integrity": "sha512-gtGo1B+u14jrZQv6TdSWIWkTqclboo7Qn+dFAGUOIuXLFuJKAdg3U5MIWzZnW4vfUb4dX9skmAMiby86e/SF4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/babel__core": "^7.20.5" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs3": { - "version": "1.0.0", - "integrity": "sha512-yIkslVjbmml2Xjb6XhFW7lISXHsqk6cesxTdDsXoMom4Lnb99DbD3OQbSOoM5Z+ASh8YXYaLAsRQrU2Jeh3Qig==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^1.0.0", - "core-js-compat": "^3.48.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0" - } - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.2.0", - "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5" - }, - "peerDependencies": { - "@babel/core": "^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/babel-preset-current-node-syntax/node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.29.7", - "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "node_modules/@yuku-parser/binding-linux-arm-musl": { + "version": "0.10.2", + "integrity": "sha512-A+Cb0I1hFF4wilTQpWs79k1aNnj4B2tkrHx3zsuUNF9BtVY2zPZ4yeQEM/zjXrS/qJlNMZVK9NrWJjwdpnTrfg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "node_modules/@yuku-parser/binding-linux-arm64-gnu": { + "version": "0.10.2", + "integrity": "sha512-aGNSzqIqqphFwAdIFwVvKIyXD1Iy3CxrEJVIZAT87Ecyi4vCmmDD2v2l9h+Gd3/wSy3JZlOI792LJbKAjyy9rQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "node_modules/@yuku-parser/binding-linux-arm64-musl": { + "version": "0.10.2", + "integrity": "sha512-Ddl1sF0rtuCXyHGSOV6dcmdx+ETv1iD+IVFqQuOjzkJZWclxJxdBWX6ZJMRtTiGqGUTbqLEKiTXxpR/Bvqk+2A==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "node_modules/@yuku-parser/binding-linux-x64-gnu": { + "version": "0.10.2", + "integrity": "sha512-/nlcpR6IF5U+0m5L9wvAIXeQa2DT+BXuvqKDsTcOTlcc0B4dHNyqE5hTXsS4hAyimpy2ZK8A1zMNF5hTrjg2cg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "node_modules/@yuku-parser/binding-linux-x64-musl": { + "version": "0.10.2", + "integrity": "sha512-GX80dxTQD/M/OryyuxJcFzFENzry3cDFPvCFTyWogNWQH3fSHfMrNfpwpl1YzMos1eV9NygK5GSDG7VArQlb4w==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "node_modules/@yuku-parser/binding-win32-arm64": { + "version": "0.10.2", + "integrity": "sha512-agePQBV4VHewiGU0ACSjscZ/hJd283f9JfAF19Gf1rI5+wy5tTgx4GS36i0b3xim15AQ4RCpDRosEvhLZ4zAOw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "node_modules/@yuku-parser/binding-win32-x64": { + "version": "0.10.2", + "integrity": "sha512-s8//CMpgL5+y1lvDCdyh1rwGI5+ytDJywFJRe1vnhI7n0j+caxshNucurikYLLVeQ2SYFex6aPrzgQxkabBQRA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "node_modules/@yuku-toolchain/types": { + "version": "0.10.2", + "integrity": "sha512-sSeo4SSSToiS+sSD+bwn/s94EEcaLJ7tG5LCp8gFYC1G5VzxzX7fqB6m9RU1yMD8KTpu6zdU/I1NlQf8hVBJ9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "2.0.0", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "engines": { + "node": ">= 0.6" } }, - "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "node_modules/acorn": { + "version": "8.18.0", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "bin": { + "acorn": "bin/acorn" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=0.4.0" } }, - "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "node_modules/acorn-jsx": { + "version": "5.3.2", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, "peerDependencies": { - "@babel/core": "^7.0.0-0" + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/babel-preset-jest": { - "version": "30.5.0", - "integrity": "sha512-ZGPn5ClP4lBDpuOK8W1yQIOy359HmbnZv3suucAlIe+SEE9yDsxV4S2PjSbH2Vc97U+WmzYD7vw3kr8NsQ/i6w==", + "node_modules/adjust-sourcemap-loader": { + "version": "4.0.0", + "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "babel-plugin-jest-hoist": "30.5.0", - "babel-preset-current-node-syntax": "^1.2.0" + "loader-utils": "^2.0.0", + "regex-parser": "^2.2.11" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0" + "node": ">=8.9" } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/adjust-sourcemap-loader/node_modules/loader-utils": { + "version": "2.0.4", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, "engines": { - "node": "18 || 20 || >=22" + "node": ">=8.9.0" } }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.25", - "integrity": "sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==", + "node_modules/agent-base": { + "version": "9.0.0", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, + "license": "MIT", "engines": { - "node": ">=6.0.0" + "node": ">= 20" } }, - "node_modules/batch": { - "version": "0.6.1", - "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "node_modules/ajv": { + "version": "8.20.0", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true - }, - "node_modules/beasties": { - "version": "0.4.3", - "integrity": "sha512-fIIeLOcbAB/K1kb1HBVJoiq1alHL4RCYBSo5e7HzrNkkgMggXR1Vqt/Z9JWnkfe/qdCo66Ux3QRwZioAIBdWRA==", - "dev": true, - "license": "Apache-2.0", "dependencies": { - "css-select": "^6.0.0", - "css-what": "^7.0.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "htmlparser2": "^10.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.49", - "postcss-media-query-parser": "^0.2.3", - "postcss-safe-parser": "^7.0.1" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, - "engines": { - "node": ">=18.0.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/bidi-js": { - "version": "1.1.0", - "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "node_modules/ajv-formats": { + "version": "3.0.1", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "require-from-string": "^2.0.2" + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } } }, - "node_modules/big.js": { - "version": "5.2.2", - "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "node_modules/ajv-keywords": { + "version": "5.1.0", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, "license": "MIT", "peer": true, - "engines": { - "node": "*" + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "node_modules/ansi-colors": { + "version": "4.1.3", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", "dev": true, "license": "MIT", "peer": true, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6" } }, - "node_modules/body-parser": { - "version": "2.3.0", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "node_modules/ansi-escapes": { + "version": "4.3.2", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", "dev": true, "license": "MIT", "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" + "type-fest": "^0.21.3" }, "engines": { - "node": ">=18" + "node": ">=8" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.1.0", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "dev": true, - "license": "MIT", + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=18" + "node": ">=10" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/bonjour-service": { - "version": "1.4.4", - "integrity": "sha512-jCZcVv7eoc4QesRscwEZtSROBen+6LpKAmBIsQYQrsAeVHLyMXWX/t6eIV5KiRZYNUBl8eVqImEEMQ8L5+c/Kw==", + "node_modules/ansi-html-community": { + "version": "0.0.8", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", "dev": true, - "license": "MIT", + "engines": [ + "node >= 0.8.0" + ], + "license": "Apache-2.0", "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "multicast-dns": "^7.2.5" + "bin": { + "ansi-html": "bin/ansi-html" } }, - "node_modules/boolbase": { - "version": "1.0.0", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true, - "license": "ISC" - }, - "node_modules/brace-expansion": { - "version": "5.0.12", - "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "node_modules/ansi-regex": { + "version": "5.0.1", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, "engines": { - "node": "20 || >=22" + "node": ">=8" } }, - "node_modules/braces": { - "version": "3.0.3", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/ansi-styles": { + "version": "4.3.0", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "fill-range": "^7.1.1" + "color-convert": "^2.0.1" }, "engines": { "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.29.0", - "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.11.23", - "caniuse-lite": "^1.0.30001810", - "electron-to-chromium": "^1.5.427", - "node-releases": "^2.0.55", - "update-browserslist-db": "^1.3.3" - }, - "bin": { - "browserslist": "cli.js" }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/bs-logger": { - "version": "0.2.6", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "node_modules/anymatch": { + "version": "3.1.3", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "fast-json-stable-stringify": "2.x" + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" }, "engines": { - "node": ">= 6" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "node-int64": "^0.4.0" + "node": ">= 8" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/builtin-modules": { - "version": "3.3.0", - "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=6" + "node": ">=8.6" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/builtins": { - "version": "5.1.0", - "integrity": "sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg==", + "node_modules/argparse": { + "version": "2.0.1", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "semver": "^7.0.0" - } + "license": "Python-2.0" }, - "node_modules/bundle-name": { - "version": "4.1.0", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "run-applescript": "^7.0.0" + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" }, "engines": { - "node": ">=18" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/bytes": { - "version": "3.1.2", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "node_modules/array-includes": { + "version": "3.2.0", + "integrity": "sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg==", "dev": true, "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "es-shim-unscopables": "^1.1.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, "engines": { - "node": ">= 0.8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/bytestreamjs": { - "version": "2.0.1", - "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", "dev": true, - "license": "BSD-3-Clause", - "peer": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, "engines": { - "node": ">=6.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/call-bind": { - "version": "1.0.9", - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "get-intrinsic": "^1.3.0", - "set-function-length": "^1.2.2" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -9506,27 +8743,37 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/call-bound": { + "node_modules/arraybuffer.prototype.slice": { "version": "1.0.4", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" }, "engines": { "node": ">= 0.4" @@ -9535,849 +8782,737 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/asn1js": { + "version": "3.0.10", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, "engines": { - "node": ">=6" + "node": ">=12.0.0" } }, - "node_modules/camelcase": { - "version": "5.3.1", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "node_modules/async-function": { + "version": "1.0.0", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 0.4" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001810", - "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "node_modules/autoprefixer": { + "version": "10.6.0", + "integrity": "sha512-A26d6qs9kqGgkmImIXMYvXTzqb4Qv7AVgpY1NXzr9Y659J8qHHnLOO/zE8ewIGFMprOolAoRAQYDgNryXIJKBw==", "dev": true, "funding": [ { "type": "opencollective", - "url": "https://opencollective.com/browserslist" + "url": "https://opencollective.com/postcss/" }, { "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + "url": "https://tidelift.com/funding/github/npm/autoprefixer" }, { "type": "github", "url": "https://github.com/sponsors/ai" } ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" + "browserslist": "^4.28.9", + "caniuse-lite": "^1.0.30001810", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" }, - "engines": { - "node": ">=10" + "bin": { + "autoprefixer": "bin/autoprefixer" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/char-regex": { - "version": "1.0.2", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=10" + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" } }, - "node_modules/chardet": { - "version": "2.2.0", - "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", - "dev": true, - "license": "MIT" - }, - "node_modules/chokidar": { - "version": "5.0.0", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, "license": "MIT", "dependencies": { - "readdirp": "^5.0.0" + "possible-typed-array-names": "^1.0.0" }, "engines": { - "node": ">= 20.19.0" + "node": ">= 0.4" }, "funding": { - "url": "https://paulmillr.com/funding/" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.0" + "node_modules/aws-rum-web": { + "version": "3.2.1", + "integrity": "sha512-DBMYmZPBfCtK1ikXjGP5OE9yqiOymtHibn6BO3IFzK91td2tflYaq3ecjv+FDmai6uPXjjF7xjmdhK7gvVoa9A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-rum/web-core": "3.2.1", + "@aws-rum/web-slim": "3.2.1" } }, - "node_modules/ci-info": { - "version": "4.4.0", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "node_modules/axe-core": { + "version": "4.13.0", + "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", + "license": "MPL-2.0", "engines": { - "node": ">=8" + "node": ">=4" } }, - "node_modules/cjs-module-lexer": { - "version": "2.2.1", - "integrity": "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "node_modules/babel-jest": { + "version": "30.5.2", + "integrity": "sha512-ES8WeJ2fNWPEDunyAtUfJ12nHyaWUloCHdkfK2oW5uSoQSUmjNKdWiGaXJITJwvFEWoD/evHEg/QoCXAvLaQqQ==", "dev": true, "license": "MIT", "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" + "@jest/transform": "30.5.2", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^8.0.0", + "babel-preset-jest": "30.5.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-spinners": { - "version": "3.4.0", - "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=18.20" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" } }, - "node_modules/cli-truncate": { - "version": "6.1.1", - "integrity": "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==", + "node_modules/babel-loader": { + "version": "10.1.1", + "integrity": "sha512-JwKSzk2kjIe7mgPK+/lyZ2QAaJcpahNAdM+hgR2HI8D0OJVkdj8Rl6J3kaLYki9pwF7P2iWnD8qVv80Lq1ABtg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "slice-ansi": "^9.0.0", - "string-width": "^8.2.0" + "find-up": "^5.0.0" }, "engines": { - "node": ">=22" + "node": "^18.20.0 || ^20.10.0 || >=22.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-width": { - "version": "4.1.0", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/cliui": { - "version": "9.0.1", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" + "peerDependencies": { + "@babel/core": "^7.12.0 || ^8.0.0-beta.1", + "@rspack/core": "^1.0.0 || ^2.0.0-0", + "webpack": ">=5.61.0" }, - "engines": { - "node": ">=20" + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } } }, - "node_modules/cliui/node_modules/ansi-regex": { - "version": "6.3.0", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "node_modules/babel-plugin-istanbul": { + "version": "8.0.0", + "integrity": "sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^7.0.1" }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=18" } }, - "node_modules/cliui/node_modules/string-width": { - "version": "7.2.0", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/babel-plugin-istanbul/node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/cliui/node_modules/strip-ansi": { - "version": "7.2.0", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "node_modules/babel-plugin-jest-hoist": { + "version": "30.5.0", + "integrity": "sha512-gtGo1B+u14jrZQv6TdSWIWkTqclboo7Qn+dFAGUOIuXLFuJKAdg3U5MIWzZnW4vfUb4dX9skmAMiby86e/SF4A==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.2.2" + "@types/babel__core": "^7.20.5" }, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "9.0.2", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "1.0.0", + "integrity": "sha512-yIkslVjbmml2Xjb6XhFW7lISXHsqk6cesxTdDsXoMom4Lnb99DbD3OQbSOoM5Z+ASh8YXYaLAsRQrU2Jeh3Qig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "@babel/helper-define-polyfill-provider": "^1.0.0", + "core-js-compat": "^3.48.0" }, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0" } }, - "node_modules/clone-deep": { - "version": "4.0.1", - "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "is-plain-object": "^2.0.4", - "kind-of": "^6.0.2", - "shallow-clone": "^3.0.0" + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" }, - "engines": { - "node": ">=6" + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" } }, - "node_modules/co": { - "version": "4.6.0", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "node_modules/babel-preset-current-node-syntax/node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" + "node": ">=6.9.0" } }, - "node_modules/collect-v8-coverage": { - "version": "1.0.3", - "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", - "dev": true, - "license": "MIT" - }, - "node_modules/color-convert": { - "version": "2.0.1", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", "dev": true, "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "@babel/helper-plugin-utils": "^7.8.0" }, - "engines": { - "node": ">=7.0.0" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/color-name": { - "version": "1.1.4", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/colorette": { - "version": "2.0.20", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", "dev": true, "license": "MIT", - "peer": true + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/commander": { - "version": "15.0.0", - "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=22.12.0" + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/common-path-prefix": { - "version": "3.0.0", - "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", - "dev": true, - "license": "ISC" - }, - "node_modules/compressible": { - "version": "2.0.18", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "mime-db": ">= 1.43.0 < 2" + "@babel/helper-plugin-utils": "^7.14.5" }, "engines": { - "node": ">= 0.6" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/compression": { - "version": "1.8.2", - "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", + "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "destroy": "1.2.0", - "negotiator": "~0.6.4", - "on-headers": "~1.1.0", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { - "node": ">= 0.8.0" + "node": ">=6.9.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/compression/node_modules/debug": { - "version": "2.6.9", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "ms": "2.0.0" + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/compression/node_modules/ms": { - "version": "2.0.0", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", "dev": true, "license": "MIT", - "peer": true + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/compression/node_modules/negotiator": { - "version": "0.6.4", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } }, - "node_modules/connect-history-api-fallback": { - "version": "2.0.0", - "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.8" + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/content-disposition": { - "version": "1.1.0", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/content-type": { - "version": "1.0.5", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/convert-source-map": { - "version": "1.9.0", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.2", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, "engines": { - "node": ">=6.6.0" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/copy-anything": { - "version": "3.0.5", - "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "node_modules/babel-preset-current-node-syntax/node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", "dev": true, "license": "MIT", "dependencies": { - "is-what": "^4.1.8" + "@babel/helper-plugin-utils": "^7.14.5" }, "engines": { - "node": ">=12.13" + "node": ">=6.9.0" }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "node_modules/copy-webpack-plugin": { - "version": "14.0.0", - "integrity": "sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==", + "node_modules/babel-preset-jest": { + "version": "30.5.0", + "integrity": "sha512-ZGPn5ClP4lBDpuOK8W1yQIOy359HmbnZv3suucAlIe+SEE9yDsxV4S2PjSbH2Vc97U+WmzYD7vw3kr8NsQ/i6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "glob-parent": "^6.0.1", - "normalize-path": "^3.0.0", - "schema-utils": "^4.2.0", - "serialize-javascript": "^7.0.3", - "tinyglobby": "^0.2.12" + "babel-plugin-jest-hoist": "30.5.0", + "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { - "node": ">= 20.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "webpack": "^5.1.0" + "@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0" } }, - "node_modules/core-js-compat": { - "version": "3.50.0", - "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==", + "node_modules/balanced-match": { + "version": "4.0.4", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "browserslist": "^4.28.7" - }, "engines": { - "node": ">=6.4.0" + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.26", + "integrity": "sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" + "engines": { + "node": ">=6.0.0" } }, - "node_modules/core-util-is": { - "version": "1.0.3", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "node_modules/batch": { + "version": "0.6.1", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", "dev": true, "license": "MIT", "peer": true }, - "node_modules/cors": { - "version": "2.8.6", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "node_modules/beasties": { + "version": "0.5.4", + "integrity": "sha512-8/lOOjT+PElYCnaUbmhgd3Uad5R1VTQ4Wg0k4rK8bX04m5WEqHNmnlLy3Q/lJ7dk25XA+72gayzc/skTfKKpcA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "object-assign": "^4", - "vary": "^1" + "css-select": "^7.0.0", + "css-what": "^8.0.0", + "dom-serializer": "^3.0.0", + "domhandler": "^6.0.0", + "htmlparser2": "^12.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.49", + "postcss-media-query-parser": "^0.2.3", + "postcss-safe-parser": "^7.0.1" }, "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=20.19.0" } }, - "node_modules/cosmiconfig": { - "version": "9.0.2", - "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", + "node_modules/bidi-js": { + "version": "1.1.0", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } + "require-from-string": "^2.0.2" } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/big.js": { + "version": "5.2.2", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", "dev": true, "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, + "peer": true, "engines": { - "node": ">= 8" + "node": "*" } }, - "node_modules/css-loader": { - "version": "7.1.4", - "integrity": "sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==", + "node_modules/body-parser": { + "version": "2.3.0", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "icss-utils": "^5.1.0", - "postcss": "^8.4.40", - "postcss-modules-extract-imports": "^3.1.0", - "postcss-modules-local-by-default": "^4.0.5", - "postcss-modules-scope": "^3.2.0", - "postcss-modules-values": "^4.0.0", - "postcss-value-parser": "^4.2.0", - "semver": "^7.6.3" + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { - "node": ">= 18.12.0" + "node": ">=18" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", - "webpack": "^5.27.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } + "url": "https://opencollective.com/express" } }, - "node_modules/css-select": { - "version": "6.0.0", - "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^7.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "nth-check": "^2.1.1" + "license": "MIT", + "engines": { + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/fb55" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/css-tree": { - "version": "3.2.1", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "node_modules/bonjour-service": { + "version": "1.4.4", + "integrity": "sha512-jCZcVv7eoc4QesRscwEZtSROBen+6LpKAmBIsQYQrsAeVHLyMXWX/t6eIV5KiRZYNUBl8eVqImEEMQ8L5+c/Kw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" } }, - "node_modules/css-what": { - "version": "7.0.0", - "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", + "node_modules/boolbase": { + "version": "2.0.0", + "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==", "dev": true, - "license": "BSD-2-Clause", + "license": "ISC", "engines": { - "node": ">= 6" + "node": ">=20.19.0" }, "funding": { + "type": "github", "url": "https://github.com/sponsors/fb55" } }, - "node_modules/cssesc": { - "version": "3.0.0", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cssstyle": { - "version": "4.6.0", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "node_modules/brace-expansion": { + "version": "5.0.12", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" + "dependencies": { + "balanced-match": "^4.0.2" }, "engines": { - "node": ">=18" + "node": "20 || >=22" } }, - "node_modules/cssstyle/node_modules/@asamuzakjp/css-color": { - "version": "3.2.0", - "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "node_modules/braces": { + "version": "3.0.3", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" } }, - "node_modules/cssstyle/node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "node_modules/browserslist": { + "version": "4.29.3", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", "dev": true, "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, { "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "node_modules/cssstyle/node_modules/@csstools/css-calc": { - "version": "2.1.4", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, - "funding": [ + "url": "https://opencollective.com/browserslist" + }, { - "type": "github", - "url": "https://github.com/sponsors/csstools" + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" }, { - "type": "opencollective", - "url": "https://opencollective.com/csstools" + "type": "github", + "url": "https://github.com/sponsors/ai" } ], "license": "MIT", - "engines": { - "node": ">=18" + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/cssstyle/node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "node_modules/bs-logger": { + "version": "0.2.6", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], "license": "MIT", "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" + "fast-json-stable-stringify": "2.x" }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" + "node": ">= 6" } }, - "node_modules/cssstyle/node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "node_modules/bser": { + "version": "2.1.1", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" } }, - "node_modules/cssstyle/node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "node_modules/buffer-from": { + "version": "1.1.2", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], "license": "MIT", + "peer": true, "engines": { - "node": ">=18" + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cssstyle/node_modules/lru-cache": { - "version": "10.4.3", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "node_modules/builtins": { + "version": "5.1.0", + "integrity": "sha512-SW9lzGTLvWTP1AY8xeAMZimqDrIaSdLQUcVr9DMef51niJ022Ri87SwRRKYm4A6iHfkPaiVUu/Duw2Wc4J7kKg==", "dev": true, - "license": "ISC" + "license": "MIT", + "peer": true, + "dependencies": { + "semver": "^7.0.0" + } }, - "node_modules/data-urls": { - "version": "7.0.0", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "node_modules/bundle-name": { + "version": "4.1.1", + "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" + "run-applescript": "^7.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/data-urls/node_modules/whatwg-url": { - "version": "16.0.1", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "node_modules/bytes": { + "version": "3.1.2", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", "dev": true, "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", "peer": true, - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=6.0.0" } }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "node_modules/call-bind": { + "version": "1.0.9", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" }, "engines": { "node": ">= 0.4" @@ -10386,32 +9521,27 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/data-view-byte-length": { + "node_modules/call-bind-apply-helpers": { "version": "1.0.2", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" + "function-bind": "^1.1.2" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" } }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "node_modules/call-bound": { + "version": "1.0.4", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -10420,1845 +9550,1955 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/debug": { - "version": "4.4.3", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "node_modules/callsites": { + "version": "3.1.0", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001814", + "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { - "node": ">=6.0" + "node": ">=10" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/decimal.js": { - "version": "10.6.0", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, - "node_modules/dedent": { - "version": "1.7.2", - "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "node_modules/char-regex": { + "version": "1.0.2", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true, "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } + "engines": { + "node": ">=10" } }, - "node_modules/deep-is": { - "version": "0.1.4", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "node_modules/chardet": { + "version": "2.2.0", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", "dev": true, "license": "MIT" }, - "node_modules/deepmerge": { - "version": "4.3.1", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "node_modules/chokidar": { + "version": "5.0.0", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", "dev": true, "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, - "node_modules/default-browser": { - "version": "5.5.1", - "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", "dev": true, "license": "MIT", "peer": true, - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6.0" } }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "node_modules/ci-info": { + "version": "4.4.0", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], "license": "MIT", - "peer": true, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "node_modules/cjs-module-lexer": { + "version": "2.2.1", + "integrity": "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", "dev": true, "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" + "restore-cursor": "^5.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "node_modules/cli-spinners": { + "version": "3.4.0", + "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=12" + "node": ">=18.20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/define-properties": { - "version": "1.2.1", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "node_modules/cli-truncate": { + "version": "6.1.1", + "integrity": "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==", "dev": true, "license": "MIT", "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" }, "engines": { - "node": ">= 0.4" + "node": ">=22" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/depd": { - "version": "2.0.0", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "node_modules/cli-width": { + "version": "4.1.0", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, - "license": "MIT", + "license": "ISC", "engines": { - "node": ">= 0.8" + "node": ">= 12" } }, - "node_modules/dependency-graph": { - "version": "1.0.0", - "integrity": "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==", + "node_modules/cliui": { + "version": "9.0.1", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, "engines": { - "node": ">=4" + "node": ">=20" } }, - "node_modules/destroy": { - "version": "1.2.0", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "node_modules/cliui/node_modules/ansi-regex": { + "version": "6.4.0", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/detect-newline": { - "version": "3.1.0", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "node_modules/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/detect-node": { - "version": "2.1.0", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/dns-packet": { - "version": "5.6.1", - "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@leichtgewicht/ip-codec": "^2.0.1" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=6" - } - }, - "node_modules/doctrine": { - "version": "3.0.0", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" + "node": ">=18" }, - "engines": { - "node": ">=6.0.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "node_modules/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" }, "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/domelementtype": { - "version": "2.3.0", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "domelementtype": "^2.3.0" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">= 4" + "node": ">=18" }, "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/domutils": { - "version": "3.2.2", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "node_modules/clone-deep": { + "version": "4.0.1", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "peer": true, "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" + "engines": { + "node": ">=6" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "node_modules/co": { + "version": "4.6.0", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", "dev": true, "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, "engines": { - "node": ">= 0.4" + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/ee-first": { - "version": "1.1.1", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", "dev": true, "license": "MIT" }, - "node_modules/electron-to-chromium": { - "version": "1.5.433", - "integrity": "sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==", - "dev": true, - "license": "ISC" - }, - "node_modules/emittery": { - "version": "0.13.1", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "node_modules/color-convert": { + "version": "2.0.1", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" + "dependencies": { + "color-name": "~1.1.4" }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" + "engines": { + "node": ">=7.0.0" } }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "node_modules/color-name": { + "version": "1.1.4", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, - "node_modules/emojis-list": { - "version": "3.0.0", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "node_modules/colorjs.io": { + "version": "0.7.1", + "integrity": "sha512-LY7OHnJZxHwT5UlzNa9bbhHHDbzB6yE5+3MIPwJEQKRvSCt/T4G7epsj+9j2BExUIIfXFIJGsIXUKdfrK9Q5tA==", "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">= 4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/color" } }, - "node_modules/empathic": { - "version": "2.1.0", - "integrity": "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==", + "node_modules/commander": { + "version": "15.0.0", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", "dev": true, "license": "MIT", "engines": { - "node": ">=14" + "node": ">=22.12.0" } }, - "node_modules/encodeurl": { - "version": "2.0.0", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "node_modules/common-path-prefix": { + "version": "3.0.0", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "license": "ISC" }, - "node_modules/enhanced-resolve": { - "version": "5.25.1", - "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "node_modules/compressible": { + "version": "2.0.18", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" + "mime-db": ">= 1.43.0 < 2" }, "engines": { - "node": ">=10.13.0" + "node": ">= 0.6" } }, - "node_modules/entities": { - "version": "4.5.0", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "node_modules/compression": { + "version": "1.8.2", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "peer": true, + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "destroy": "1.2.0", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, "engines": { - "node": ">=0.12" + "node": ">= 0.8.0" }, "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/env-paths": { - "version": "2.2.1", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, "license": "MIT", "peer": true, - "engines": { - "node": ">=6" + "dependencies": { + "ms": "2.0.0" } }, - "node_modules/environment": { - "version": "1.1.0", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.6" } }, - "node_modules/errno": { - "version": "0.1.8", - "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "node_modules/concat-map": { + "version": "0.0.1", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", "dev": true, "license": "MIT", - "optional": true, - "dependencies": { - "prr": "~1.0.1" - }, - "bin": { - "errno": "cli.js" + "peer": true, + "engines": { + "node": ">=0.8" } }, - "node_modules/error-ex": { - "version": "1.3.4", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "node_modules/content-disposition": { + "version": "1.1.0", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "dev": true, "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/es-abstract": { - "version": "1.24.2", - "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "node_modules/content-type": { + "version": "1.0.5", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", "dev": true, "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/copy-anything": { + "version": "3.0.5", + "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-what": "^4.1.8" }, "engines": { - "node": ">= 0.4" + "node": ">=12.13" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/mesqueeb" } }, - "node_modules/es-abstract-get": { - "version": "1.0.0", - "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", + "node_modules/copy-webpack-plugin": { + "version": "14.0.0", + "integrity": "sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.2", - "is-callable": "^1.2.7", - "object-inspect": "^1.13.4" + "glob-parent": "^6.0.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.2.0", + "serialize-javascript": "^7.0.3", + "tinyglobby": "^0.2.12" }, "engines": { - "node": ">= 0.4" + "node": ">= 20.9.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "node_modules/core-js-compat": { + "version": "3.50.0", + "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==", "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "browserslist": "^4.28.7" + }, "engines": { - "node": ">= 0.4" + "node": ">=6.4.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "node_modules/cors": { + "version": "2.8.6", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "dev": true, "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, "engines": { - "node": ">= 0.4" + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/es-module-lexer": { - "version": "2.3.2", - "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "node_modules/cosmiconfig": { + "version": "9.0.2", + "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true, + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "node_modules/cross-spawn": { + "version": "7.0.6", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">= 8" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "node_modules/css-loader": { + "version": "7.1.5", + "integrity": "sha512-Q7iAfQkU2twNBryKX/vGAlE+GAmkF7quhSzAGNK8fBimxk3+tqg245rH52UPb9dioBolBc8rP0ihmHBaDTJQBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "icss-utils": "^5.1.0", + "postcss": "^8.4.40", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.6.3" }, "engines": { - "node": ">= 0.4" + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } } }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "node_modules/css-select": { + "version": "7.0.0", + "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "hasown": "^2.0.2" + "boolbase": "^2.0.0", + "css-what": "^8.0.0", + "domhandler": "^6.0.1", + "domutils": "^4.0.2", + "nth-check": "^3.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/fb55" } }, - "node_modules/es-to-primitive": { - "version": "1.3.4", - "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", + "node_modules/css-tree": { + "version": "3.2.1", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "es-abstract-get": "^1.0.0", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "is-callable": "^1.2.7", - "is-date-object": "^1.1.0", - "is-symbol": "^1.1.1" + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">= 0.4" + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "8.0.0", + "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/fb55" } }, - "node_modules/esbuild": { - "version": "0.28.2", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "node_modules/cssesc": { + "version": "3.0.0", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", "dev": true, - "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { - "esbuild": "bin/esbuild" + "cssesc": "bin/cssesc" }, "engines": { - "node": ">=18" - }, - "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" + "node": ">=4" } }, - "node_modules/esbuild-wasm": { - "version": "0.28.2", - "integrity": "sha512-GccVwhv3mmOUVQHCQm2Ox/rby8n/EqUwvZxE6Pjfikrq/lWw9g9WX/u9EykWnpot3Ko6j426DgQdea2xWKIAQA==", + "node_modules/cssstyle": { + "version": "4.6.0", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" }, "engines": { "node": ">=18" } }, - "node_modules/escalade": { + "node_modules/cssstyle/node_modules/@asamuzakjp/css-color": { "version": "3.2.0", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6" + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "node_modules/cssstyle/node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", "dev": true, - "license": "MIT", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/eslint": { - "version": "8.57.1", - "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", - "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "node_modules/cssstyle/node_modules/@csstools/css-calc": { + "version": "2.1.4", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.1", - "@humanwhocodes/config-array": "^0.13.0", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=18" }, - "funding": { - "url": "https://opencollective.com/eslint" + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/eslint-compat-utils": { - "version": "0.5.1", - "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", + "node_modules/cssstyle/node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", - "peer": true, "dependencies": { - "semver": "^7.5.4" + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" }, "engines": { - "node": ">=12" + "node": ">=18" }, "peerDependencies": { - "eslint": ">=6.0.0" + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/eslint-config-prettier": { - "version": "10.1.8", - "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "node_modules/cssstyle/node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "funding": { - "url": "https://opencollective.com/eslint-config-prettier" + "engines": { + "node": ">=18" }, "peerDependencies": { - "eslint": ">=7.0.0" + "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/eslint-config-standard": { - "version": "17.1.0", - "integrity": "sha512-IwHwmaBNtDK4zDHQukFDW5u/aTb8+meQWZvNFWkiGmbWjD6bqyuSSBxxXKkCftCUzc1zwCH2m/baCNDLGmuO5Q==", + "node_modules/cssstyle/node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", "dev": true, "funding": [ { "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "url": "https://github.com/sponsors/csstools" }, { - "type": "consulting", - "url": "https://feross.org/support" + "type": "opencollective", + "url": "https://opencollective.com/csstools" } ], "license": "MIT", "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "eslint": "^8.0.1", - "eslint-plugin-import": "^2.25.2", - "eslint-plugin-n": "^15.0.0 || ^16.0.0 ", - "eslint-plugin-promise": "^6.0.0" + "node": ">=18" } }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.10", - "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "10.4.3", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.16.1", - "resolve": "^2.0.0-next.6" + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "ms": "^2.1.1" + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/eslint-module-utils": { - "version": "2.14.0", - "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", + "node_modules/data-view-buffer": { + "version": "1.0.2", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^3.2.7" + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" }, "engines": { - "node": ">=4" + "node": ">= 0.4" }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.1" + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" } }, - "node_modules/eslint-plugin-es": { - "version": "3.0.1", - "integrity": "sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==", + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "dev": true, "license": "MIT", "dependencies": { - "eslint-utils": "^2.0.0", - "regexpp": "^3.0.0" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" }, "engines": { - "node": ">=8.10.0" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=4.19.1" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-plugin-es-x": { - "version": "7.8.0", - "integrity": "sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==", + "node_modules/debug": { + "version": "4.4.3", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, - "funding": [ - "https://github.com/sponsors/ota-meshi", - "https://opencollective.com/eslint" - ], "license": "MIT", - "peer": true, "dependencies": { - "@eslint-community/eslint-utils": "^4.1.2", - "@eslint-community/regexpp": "^4.11.0", - "eslint-compat-utils": "^0.5.1" + "ms": "^2.1.3" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": ">=6.0" }, - "peerDependencies": { - "eslint": ">=8" + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/eslint-plugin-import": { - "version": "2.32.0", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "node_modules/decimal.js": { + "version": "10.6.0", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dedent": { + "version": "1.7.2", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", "dev": true, "license": "MIT", - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } } }, - "node_modules/eslint-plugin-import/node_modules/balanced-match": { - "version": "1.0.2", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "node_modules/deep-is": { + "version": "0.1.4", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, "license": "MIT" }, - "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.21", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "node_modules/deepmerge": { + "version": "4.3.1", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "engines": { + "node": ">=0.10.0" } }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "node_modules/default-browser": { + "version": "5.5.1", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "ms": "^2.1.1" + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-plugin-import/node_modules/doctrine": { - "version": "2.1.0", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "node_modules/default-browser-id": { + "version": "5.0.1", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, + "license": "MIT", + "peer": true, "engines": { - "node": ">=0.10.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-plugin-import/node_modules/minimatch": { - "version": "3.1.5", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/define-data-property": { + "version": "1.1.4", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "brace-expansion": "^1.1.7" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { - "node": "*" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint-plugin-n": { - "version": "16.6.2", - "integrity": "sha512-6TyDmZ1HXoFQXnhCTUjVFULReoBPOAjpuiKELMkeP40yffI/1ZRO+d9ug/VC6fqISo2WkuIBk3cvuRPALaWlOQ==", + "node_modules/define-properties": { + "version": "1.2.1", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "builtins": "^5.0.1", - "eslint-plugin-es-x": "^7.5.0", - "get-tsconfig": "^4.7.0", - "globals": "^13.24.0", - "ignore": "^5.2.4", - "is-builtin-module": "^3.2.1", - "is-core-module": "^2.12.1", - "minimatch": "^3.1.2", - "resolve": "^1.22.2", - "semver": "^7.5.3" + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" }, "engines": { - "node": ">=16.0.0" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=7.0.0" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eslint-plugin-n/node_modules/balanced-match": { - "version": "1.0.2", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "node_modules/depd": { + "version": "2.0.0", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", "dev": true, "license": "MIT", - "peer": true + "engines": { + "node": ">= 0.8" + } }, - "node_modules/eslint-plugin-n/node_modules/brace-expansion": { - "version": "1.1.21", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "node_modules/dependency-graph": { + "version": "1.0.0", + "integrity": "sha512-cW3gggJ28HZ/LExwxP2B++aiKxhJXMSIt9K48FOXQkm+vuG5gyatXnLsONRJdzO/7VfjDIiaOOa/bs4l464Lwg==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "engines": { + "node": ">=4" } }, - "node_modules/eslint-plugin-n/node_modules/ignore": { - "version": "5.3.2", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/destroy": { + "version": "1.2.0", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", "dev": true, "license": "MIT", "peer": true, "engines": { - "node": ">= 4" + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" } }, - "node_modules/eslint-plugin-n/node_modules/minimatch": { - "version": "3.1.5", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/detect-libc": { + "version": "2.1.2", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "license": "Apache-2.0", "engines": { - "node": "*" + "node": ">=8" } }, - "node_modules/eslint-plugin-n/node_modules/resolve": { - "version": "1.22.12", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "node_modules/detect-newline": { + "version": "3.1.0", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" + "@leichtgewicht/ip-codec": "^2.0.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=6" } }, - "node_modules/eslint-plugin-node": { - "version": "11.1.0", - "integrity": "sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==", + "node_modules/doctrine": { + "version": "3.0.0", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "eslint-plugin-es": "^3.0.0", - "eslint-utils": "^2.0.0", - "ignore": "^5.1.1", - "minimatch": "^3.0.4", - "resolve": "^1.10.1", - "semver": "^6.1.0" + "esutils": "^2.0.2" }, "engines": { - "node": ">=8.10.0" - }, - "peerDependencies": { - "eslint": ">=5.16.0" + "node": ">=6.0.0" } }, - "node_modules/eslint-plugin-node/node_modules/balanced-match": { - "version": "1.0.2", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint-plugin-node/node_modules/brace-expansion": { - "version": "1.1.21", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "node_modules/dom-serializer": { + "version": "3.1.1", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, - "node_modules/eslint-plugin-node/node_modules/ignore": { - "version": "5.3.2", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/domelementtype": { + "version": "3.0.0", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", "dev": true, - "license": "MIT", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", "engines": { - "node": ">= 4" + "node": ">=20.19.0" } }, - "node_modules/eslint-plugin-node/node_modules/minimatch": { - "version": "3.1.5", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/domhandler": { + "version": "6.0.1", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", "dev": true, - "license": "ISC", + "license": "BSD-2-Clause", "dependencies": { - "brace-expansion": "^1.1.7" + "domelementtype": "^3.0.0" }, "engines": { - "node": "*" + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" } }, - "node_modules/eslint-plugin-node/node_modules/resolve": { - "version": "1.22.12", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "node_modules/domutils": { + "version": "4.0.2", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=20.19.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/eslint-plugin-node/node_modules/semver": { - "version": "6.3.1", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" } }, - "node_modules/eslint-plugin-prettier": { - "version": "5.5.6", - "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", + "node_modules/dts-resolver": { + "version": "3.0.0", + "integrity": "sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==", "dev": true, "license": "MIT", - "dependencies": { - "prettier-linter-helpers": "^1.0.1", - "synckit": "^0.11.13" - }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^22.18.0 || >=24.0.0" }, "funding": { - "url": "https://opencollective.com/eslint-plugin-prettier" + "url": "https://github.com/sponsors/sxzz" }, "peerDependencies": { - "@types/eslint": ">=8.0.0", - "eslint": ">=8.0.0", - "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", - "prettier": ">=3.0.0" + "oxc-resolver": ">=11.0.0" }, "peerDependenciesMeta": { - "@types/eslint": { - "optional": true - }, - "eslint-config-prettier": { + "oxc-resolver": { "optional": true } } }, - "node_modules/eslint-plugin-promise": { - "version": "6.6.0", - "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", + "node_modules/dunder-proto": { + "version": "1.0.1", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, - "license": "ISC", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" }, - "peerDependencies": { - "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" + "engines": { + "node": ">= 0.4" } }, - "node_modules/eslint-plugin-standard": { - "version": "5.0.0", - "integrity": "sha512-eSIXPc9wBM4BrniMzJRBm2uoVuXz2EPa+NXPk2+itrVt+r5SbKFERx/IgrK/HmfjddyKVz2f+j+7gBRvu19xLg==", - "deprecated": "standard 16.0.0 and eslint-config-standard 16.0.0 no longer require the eslint-plugin-standard package. You can remove it from your dependencies with 'npm rm eslint-plugin-standard'. More info here: https://github.com/standard/standard/issues/1316", + "node_modules/eastasianwidth": { + "version": "0.2.0", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "peerDependencies": { - "eslint": ">=5.0.0" - } + "license": "MIT" }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "node_modules/ee-first": { + "version": "1.1.1", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.442", + "integrity": "sha512-najZYZ3+ZpjN1z3VOsrBiv5ej18vQgfV2lvEmxWzfmKrk4bdm3Owseyud4yGU6DfFn9pyunoAHDDyM4KmP78Ew==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=12" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sindresorhus/emittery?sponsor=1" } }, - "node_modules/eslint-utils": { - "version": "2.1.0", - "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", + "node_modules/emoji-regex": { + "version": "10.6.0", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", "dev": true, "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^1.1.0" - }, + "peer": true, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" + "node": ">= 4" } }, - "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "1.3.0", - "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", + "node_modules/empathic": { + "version": "2.1.0", + "integrity": "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": ">=4" + "node": ">=14" } }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/encodeurl": { + "version": "2.0.0", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" }, - "funding": { - "url": "https://opencollective.com/eslint" + "engines": { + "node": ">=10.13.0" } }, - "node_modules/eslint/node_modules/ajv": { - "version": "6.15.0", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "node_modules/entities": { + "version": "8.1.0", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", "dev": true, - "license": "MIT", - "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" + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "1.0.2", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.21", - "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "node_modules/env-paths": { + "version": "2.2.1", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "peer": true, + "engines": { + "node": ">=6" } }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "node_modules/environment": { + "version": "1.1.0", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", "dev": true, "license": "MIT", "engines": { - "node": ">= 4" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/eslint/node_modules/json-schema-traverse": { - "version": "0.4.1", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/errno": { + "version": "0.1.8", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", "dev": true, - "license": "ISC", + "license": "MIT", + "optional": true, "dependencies": { - "brace-expansion": "^1.1.7" + "prr": "~1.0.1" }, - "engines": { - "node": "*" + "bin": { + "errno": "cli.js" } }, - "node_modules/espree": { - "version": "9.6.1", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "node_modules/error-ex": { + "version": "1.3.4", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" + "is-arrayish": "^0.2.1" } }, - "node_modules/esprima": { - "version": "4.0.1", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "node_modules/es-abstract": { + "version": "1.24.2", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" }, "engines": { - "node": ">=4" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esquery": { - "version": "1.7.0", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "node_modules/es-abstract-get": { + "version": "1.0.0", + "integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "estraverse": "^5.1.0" + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.2", + "is-callable": "^1.2.7", + "object-inspect": "^1.13.4" }, "engines": { - "node": ">=0.10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/esrecurse": { - "version": "4.3.0", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "node_modules/es-define-property": { + "version": "1.0.1", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, + "license": "MIT", "engines": { - "node": ">=4.0" + "node": ">= 0.4" } }, - "node_modules/estraverse": { - "version": "5.3.0", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "node_modules/es-errors": { + "version": "1.3.0", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "engines": { - "node": ">=4.0" + "node": ">= 0.4" } }, - "node_modules/estree-walker": { - "version": "2.0.2", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "node_modules/es-module-lexer": { + "version": "2.3.2", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, - "node_modules/esutils": { - "version": "2.0.3", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "node_modules/es-object-atoms": { + "version": "1.1.2", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, - "node_modules/etag": { - "version": "1.8.1", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, "engines": { - "node": ">= 0.6" + "node": ">= 0.4" } }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, "license": "MIT", - "peer": true + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } }, - "node_modules/events": { - "version": "3.3.0", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "node_modules/es-to-primitive": { + "version": "1.3.4", + "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==", "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "es-abstract-get": "^1.0.0", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "is-callable": "^1.2.7", + "is-date-object": "^1.1.0", + "is-symbol": "^1.1.1" + }, "engines": { - "node": ">=0.8.x" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/eventsource": { - "version": "3.0.7", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "node_modules/esbuild": { + "version": "0.28.2", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" + "bin": { + "esbuild": "bin/esbuild" }, "engines": { - "node": ">=18.0.0" + "node": ">=18" + }, + "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" } }, - "node_modules/eventsource-parser": { - "version": "3.1.1", - "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "node_modules/esbuild-wasm": { + "version": "0.28.2", + "integrity": "sha512-GccVwhv3mmOUVQHCQm2Ox/rby8n/EqUwvZxE6Pjfikrq/lWw9g9WX/u9EykWnpot3Ko6j426DgQdea2xWKIAQA==", "dev": true, "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, "engines": { - "node": ">=18.0.0" + "node": ">=18" } }, - "node_modules/execa": { - "version": "5.1.1", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "node_modules/escalade": { + "version": "3.2.0", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" + "node": ">=6" } }, - "node_modules/execa/node_modules/signal-exit": { - "version": "3.0.7", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "node_modules/escape-html": { + "version": "1.0.3", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "dev": true, - "license": "ISC" + "license": "MIT" }, - "node_modules/exit-x": { - "version": "0.2.2", - "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/expect": { - "version": "30.5.2", - "integrity": "sha512-0LNuMvs8/5mRYhnCR4k+lGV7fqPMs6Bnksda4S3zsCUmdxBpAn4zU4NNzXdq1pMKe/H4W5t/zVIDSJ/H3e0vDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "30.5.2", - "@jest/get-type": "30.5.0", - "jest-matcher-utils": "30.5.2", - "jest-message-util": "30.5.1", - "jest-mock": "30.5.2", - "jest-util": "30.5.1" + "node": ">=10" }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/express": { - "version": "5.2.1", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "node_modules/eslint": { + "version": "8.57.1", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" }, "engines": { - "node": ">= 18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://opencollective.com/eslint" } }, - "node_modules/express-rate-limit": { - "version": "8.7.0", - "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "node_modules/eslint-compat-utils": { + "version": "0.5.1", + "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "debug": "^4.4.3", - "ip-address": "^10.2.0" + "semver": "^7.5.4" }, "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" + "node": ">=12" }, "peerDependencies": { - "express": ">= 4.11" + "eslint": ">=6.0.0" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-diff": { - "version": "1.3.0", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-string-truncated-width": { - "version": "3.0.3", - "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-string-width": { - "version": "3.0.2", - "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", - "dependencies": { - "fast-string-truncated-width": "^3.0.2" + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" } }, - "node_modules/fast-uri": { - "version": "3.1.8", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "node_modules/eslint-config-standard": { + "version": "17.1.0", + "integrity": "sha512-IwHwmaBNtDK4zDHQukFDW5u/aTb8+meQWZvNFWkiGmbWjD6bqyuSSBxxXKkCftCUzc1zwCH2m/baCNDLGmuO5Q==", "dev": true, "funding": [ { "type": "github", - "url": "https://github.com/sponsors/fastify" + "url": "https://github.com/sponsors/feross" }, { - "type": "opencollective", - "url": "https://opencollective.com/fastify" + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause" - }, - "node_modules/fast-wrap-ansi": { - "version": "0.2.2", - "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", - "dev": true, "license": "MIT", - "dependencies": { - "fast-string-width": "^3.0.2" - } - }, - "node_modules/fastq": { - "version": "1.20.3", - "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", - "dev": true, - "license": "ISC", - "dependencies": { - "reusify": "^1.0.4" + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "eslint": "^8.0.1", + "eslint-plugin-import": "^2.25.2", + "eslint-plugin-n": "^15.0.0 || ^16.0.0 ", + "eslint-plugin-promise": "^6.0.0" } }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", "dev": true, - "license": "Apache-2.0", - "peer": true, + "license": "MIT", "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" } }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "bser": "2.1.1" + "ms": "^2.1.1" } }, - "node_modules/fdir": { - "version": "6.5.0", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/eslint-module-utils": { + "version": "2.14.0", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12.0.0" + "dependencies": { + "debug": "^3.2.7" }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "engines": { + "node": ">=4" }, "peerDependenciesMeta": { - "picomatch": { + "eslint": { "optional": true } } }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "license": "MIT", "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" + "ms": "^2.1.1" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "node_modules/eslint-plugin-es": { + "version": "3.0.1", + "integrity": "sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "to-regex-range": "^5.0.1" + "eslint-utils": "^2.0.0", + "regexpp": "^3.0.0" }, "engines": { - "node": ">=8" + "node": ">=8.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=4.19.1" } }, - "node_modules/finalhandler": { - "version": "2.1.1", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "node_modules/eslint-plugin-es-x": { + "version": "7.8.0", + "integrity": "sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==", "dev": true, + "funding": [ + "https://github.com/sponsors/ota-meshi", + "https://opencollective.com/eslint" + ], "license": "MIT", + "peer": true, "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" + "@eslint-community/eslint-utils": "^4.1.2", + "@eslint-community/regexpp": "^4.11.0", + "eslint-compat-utils": "^0.5.1" }, "engines": { - "node": ">= 18.0.0" + "node": "^14.18.0 || >=16.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "peerDependencies": { + "eslint": ">=8" } }, - "node_modules/find-cache-directory": { - "version": "6.0.0", - "integrity": "sha512-CvFd5ivA6HcSHbD+59P7CyzINHXzwhuQK8RY7CxJZtgDSAtRlHiCaQpZQ2lMR/WRyUIEmzUvL6G2AGurMfegZA==", + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", "dependencies": { - "common-path-prefix": "^3.0.0", - "pkg-dir": "^8.0.0" + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.9", + "array.prototype.findlastindex": "^1.2.6", + "array.prototype.flat": "^1.3.3", + "array.prototype.flatmap": "^1.3.3", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.1", + "hasown": "^2.0.2", + "is-core-module": "^2.16.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.1", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.9", + "tsconfig-paths": "^3.15.0" }, "engines": { - "node": ">=20" + "node": ">=4" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, - "node_modules/find-cache-directory/node_modules/pkg-dir": { - "version": "8.0.0", - "integrity": "sha512-4peoBq4Wks0riS0z8741NVv+/8IiTvqnZAr8QGgtdifrtpdXbNw/FxRS1l6NFqm4EMzuS0EDqNNx4XGaz8cuyQ==", + "node_modules/eslint-plugin-import/node_modules/balanced-match": { + "version": "1.0.2", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-import/node_modules/brace-expansion": { + "version": "1.1.21", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { - "find-up-simple": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/find-up": { - "version": "5.0.0", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", "dev": true, "license": "MIT", "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "node_modules/find-up-simple": { - "version": "1.0.1", - "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", + "node_modules/eslint-plugin-import/node_modules/minimatch": { + "version": "3.1.5", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": "*" } }, - "node_modules/flat": { - "version": "5.0.2", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, - "license": "BSD-3-Clause", - "peer": true, + "license": "ISC", "bin": { - "flat": "cli.js" + "semver": "bin/semver.js" } }, - "node_modules/flat-cache": { - "version": "3.2.0", - "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "node_modules/eslint-plugin-n": { + "version": "16.6.2", + "integrity": "sha512-6TyDmZ1HXoFQXnhCTUjVFULReoBPOAjpuiKELMkeP40yffI/1ZRO+d9ug/VC6fqISo2WkuIBk3cvuRPALaWlOQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.3", - "rimraf": "^3.0.2" + "@eslint-community/eslint-utils": "^4.4.0", + "builtins": "^5.0.1", + "eslint-plugin-es-x": "^7.5.0", + "get-tsconfig": "^4.7.0", + "globals": "^13.24.0", + "ignore": "^5.2.4", + "is-builtin-module": "^3.2.1", + "is-core-module": "^2.12.1", + "minimatch": "^3.1.2", + "resolve": "^1.22.2", + "semver": "^7.5.3" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": ">=7.0.0" } }, - "node_modules/flatted": { - "version": "3.4.4", - "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "node_modules/eslint-plugin-n/node_modules/balanced-match": { + "version": "1.0.2", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "ISC" + "license": "MIT", + "peer": true }, - "node_modules/flru": { - "version": "1.0.2", - "integrity": "sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==", + "node_modules/eslint-plugin-n/node_modules/brace-expansion": { + "version": "1.1.21", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6" + "peer": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "node_modules/eslint-plugin-n/node_modules/ignore": { + "version": "5.3.2", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], "license": "MIT", "peer": true, "engines": { - "node": ">=4.0" + "node": ">= 4" + } + }, + "node_modules/eslint-plugin-n/node_modules/minimatch": { + "version": "3.1.5", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "brace-expansion": "^1.1.7" }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } + "engines": { + "node": "*" } }, - "node_modules/for-each": { - "version": "0.3.5", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "node_modules/eslint-plugin-n/node_modules/resolve": { + "version": "1.22.12", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "is-callable": "^1.2.7" + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" }, "engines": { "node": ">= 0.4" @@ -12267,98 +11507,76 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "node_modules/eslint-plugin-node": { + "version": "11.1.0", + "integrity": "sha512-oUwtPJ1W0SKD0Tr+wqu92c5xuCeQqB3hSCHasn/ZgjFdA9iDGNkNf2Zi9ztY7X+hNuMib23LNGRm6+uN+KLE3g==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" + "eslint-plugin-es": "^3.0.0", + "eslint-utils": "^2.0.0", + "ignore": "^5.1.1", + "minimatch": "^3.0.4", + "resolve": "^1.10.1", + "semver": "^6.1.0" }, "engines": { - "node": ">=14" + "node": ">=8.10.0" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "peerDependencies": { + "eslint": ">=5.16.0" } }, - "node_modules/forwarded": { - "version": "0.2.0", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "node_modules/eslint-plugin-node/node_modules/balanced-match": { + "version": "1.0.2", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } + "license": "MIT" }, - "node_modules/fraction.js": { - "version": "5.3.4", - "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "node_modules/eslint-plugin-node/node_modules/brace-expansion": { + "version": "1.1.21", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": "*" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/rawify" + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/fresh": { - "version": "2.0.0", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "node_modules/eslint-plugin-node/node_modules/ignore": { + "version": "5.3.2", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">= 4" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/eslint-plugin-node/node_modules/minimatch": { + "version": "3.1.5", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "*" } }, - "node_modules/function.prototype.name": { - "version": "1.2.0", - "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", + "node_modules/eslint-plugin-node/node_modules/resolve": { + "version": "1.22.12", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2", - "hasown": "^2.0.4", - "is-callable": "^1.2.7", - "is-document.all": "^1.0.0" + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" }, "engines": { "node": ">= 0.4" @@ -12367,815 +11585,991 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "node_modules/eslint-plugin-node/node_modules/semver": { + "version": "6.3.1", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.6", + "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", "dev": true, "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.13" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } } }, - "node_modules/generator-function": { - "version": "2.0.1", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "node_modules/eslint-plugin-promise": { + "version": "6.6.0", + "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", "dev": true, - "license": "MIT", + "license": "ISC", "engines": { - "node": ">= 0.4" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "node_modules/eslint-plugin-standard": { + "version": "5.0.0", + "integrity": "sha512-eSIXPc9wBM4BrniMzJRBm2uoVuXz2EPa+NXPk2+itrVt+r5SbKFERx/IgrK/HmfjddyKVz2f+j+7gBRvu19xLg==", + "deprecated": "standard 16.0.0 and eslint-config-standard 16.0.0 no longer require the eslint-plugin-standard package. You can remove it from your dependencies with 'npm rm eslint-plugin-standard'. More info here: https://github.com/standard/standard/issues/1316", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" + "peerDependencies": { + "eslint": ">=5.0.0" } }, - "node_modules/get-east-asian-width": { - "version": "1.7.0", - "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", + "node_modules/eslint-scope": { + "version": "7.2.2", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "node_modules/eslint-utils": { + "version": "2.1.0", + "integrity": "sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" + "eslint-visitor-keys": "^1.1.0" }, "engines": { - "node": ">= 0.4" + "node": ">=6" }, "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-package-type": { - "version": "0.1.0", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0.0" + "url": "https://github.com/sponsors/mysticatea" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "1.3.0", + "integrity": "sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ==", "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, + "license": "Apache-2.0", "engines": { - "node": ">= 0.4" + "node": ">=4" } }, - "node_modules/get-stream": { - "version": "6.0.1", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": ">=10" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://opencollective.com/eslint" } }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "node_modules/eslint/node_modules/ajv": { + "version": "6.15.0", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/get-tsconfig": { - "version": "4.14.3", - "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.21", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/glob": { - "version": "13.0.6", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, + "license": "MIT", "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">= 4" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { - "is-glob": "^4.0.3" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=10.13.0" + "node": "*" } }, - "node_modules/glob-to-regex.js": { - "version": "1.3.1", - "integrity": "sha512-zhWhMRsgnOym+1txhUmYJGD6eUsbUdrZv55gCYKuJX8t2p3+M8My23PD09bEIsreGUs5pnSa/idmcfp2/IlqGg==", + "node_modules/espree": { + "version": "9.6.1", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, - "license": "Apache-2.0", - "peer": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, "engines": { - "node": ">=10.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" }, - "peerDependencies": { - "tslib": "2" + "engines": { + "node": ">=4" } }, - "node_modules/globals": { - "version": "13.24.0", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "node_modules/esquery": { + "version": "1.7.0", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "type-fest": "^0.20.2" + "estraverse": "^5.1.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10" } }, - "node_modules/globalthis": { - "version": "1.0.4", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "node_modules/esrecurse": { + "version": "4.3.0", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" + "estraverse": "^5.2.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=4.0" } }, - "node_modules/gopd": { - "version": "1.2.0", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "node_modules/estraverse": { + "version": "5.3.0", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=4.0" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "node_modules/esutils": { + "version": "2.0.3", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, - "license": "ISC" + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } }, - "node_modules/graphemer": { - "version": "1.4.0", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "node_modules/etag": { + "version": "1.8.1", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">= 0.6" + } }, - "node_modules/handle-thing": { - "version": "2.0.1", - "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "node_modules/events": { + "version": "3.3.0", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "dev": true, "license": "MIT", - "peer": true + "peer": true, + "engines": { + "node": ">=0.8.x" + } }, - "node_modules/handlebars": { - "version": "4.7.9", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "node_modules/eventsource": { + "version": "3.0.7", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", "dev": true, "license": "MIT", "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" + "eventsource-parser": "^3.0.1" }, "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" + "node": ">=18.0.0" } }, - "node_modules/handlebars/node_modules/source-map": { - "version": "0.6.1", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/eventsource-parser": { + "version": "3.1.1", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=18.0.0" } }, - "node_modules/has-bigints": { - "version": "1.1.0", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "node_modules/execa": { + "version": "5.1.1", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", "dev": true, "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, "engines": { - "node": ">= 0.4" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit-x": { + "version": "0.2.2", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": ">= 0.8.0" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "node_modules/expect": { + "version": "30.5.2", + "integrity": "sha512-0LNuMvs8/5mRYhnCR4k+lGV7fqPMs6Bnksda4S3zsCUmdxBpAn4zU4NNzXdq1pMKe/H4W5t/zVIDSJ/H3e0vDA==", "dev": true, "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0" + "@jest/expect-utils": "30.5.2", + "@jest/get-type": "30.5.0", + "jest-matcher-utils": "30.5.2", + "jest-message-util": "30.5.1", + "jest-mock": "30.5.2", + "jest-util": "30.5.1" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/has-proto": { - "version": "1.2.0", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "node_modules/express": { + "version": "5.2.1", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.0" + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" }, "engines": { - "node": ">= 0.4" + "node": ">= 18" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "node_modules/express-rate-limit": { + "version": "8.7.0", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", "dev": true, "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, "engines": { - "node": ">= 0.4" + "node": ">= 16" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", "dev": true, "license": "MIT", "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "fast-string-truncated-width": "^3.0.2" } }, - "node_modules/hasown": { - "version": "2.0.4", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "node_modules/fast-uri": { + "version": "3.1.8", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", "dev": true, "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" + "fast-string-width": "^3.0.2" } }, - "node_modules/highlight.js": { - "version": "11.12.0", - "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==", - "license": "BSD-3-Clause", + "node_modules/fastq": { + "version": "1.20.3", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", "engines": { "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/hono": { - "version": "4.13.8", - "integrity": "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } + "node_modules/fflate": { + "version": "0.4.9", + "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", + "license": "MIT" }, - "node_modules/hosted-git-info": { - "version": "10.1.1", - "integrity": "sha512-DeOnSPAvOndYKfw075gt8yZzQ7S2hNztw34zBTfhIzLhmBTswIBg5/y+pqu/VD5cYWm5goAFTusDmUEmKZ0PEQ==", + "node_modules/file-entry-cache": { + "version": "6.0.1", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "lru-cache": "^11.1.0" + "flat-cache": "^3.0.4" }, "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/hpack.js": { - "version": "2.1.6", - "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "node_modules/fill-range": { + "version": "7.1.1", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "inherits": "^2.0.1", - "obuf": "^1.0.0", - "readable-stream": "^2.0.1", - "wbuf": "^1.1.0" + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" } }, - "node_modules/hpack.js/node_modules/isarray": { - "version": "1.0.0", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/hpack.js/node_modules/readable-stream": { - "version": "2.3.8", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "node_modules/finalhandler": { + "version": "2.1.1", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/hpack.js/node_modules/safe-buffer": { - "version": "5.1.2", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/hpack.js/node_modules/string_decoder": { - "version": "1.1.1", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "node_modules/find-cache-directory": { + "version": "6.0.0", + "integrity": "sha512-CvFd5ivA6HcSHbD+59P7CyzINHXzwhuQK8RY7CxJZtgDSAtRlHiCaQpZQ2lMR/WRyUIEmzUvL6G2AGurMfegZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "safe-buffer": "~5.1.0" + "common-path-prefix": "^3.0.0", + "pkg-dir": "^8.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "node_modules/find-cache-directory/node_modules/pkg-dir": { + "version": "8.0.0", + "integrity": "sha512-4peoBq4Wks0riS0z8741NVv+/8IiTvqnZAr8QGgtdifrtpdXbNw/FxRS1l6NFqm4EMzuS0EDqNNx4XGaz8cuyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@exodus/bytes": "^1.6.0" + "find-up-simple": "^1.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/html-escaper": { - "version": "2.0.2", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/htmlparser2": { - "version": "10.1.0", - "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "node_modules/find-up": { + "version": "5.0.0", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], "license": "MIT", "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "entities": "^7.0.1" + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/htmlparser2/node_modules/entities": { - "version": "7.0.1", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "node_modules/find-up-simple": { + "version": "1.0.1", + "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "engines": { - "node": ">=0.12" + "node": ">=18" }, "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/http-deceiver": { - "version": "1.2.7", - "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "node_modules/flat": { + "version": "5.0.2", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "BSD-3-Clause", + "peer": true, + "bin": { + "flat": "cli.js" + } }, - "node_modules/http-errors": { - "version": "2.0.1", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "node_modules/flat-cache": { + "version": "3.2.0", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", "dev": true, "license": "MIT", "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" }, "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/http-parser-js": { - "version": "0.5.10", - "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "node_modules/flatted": { + "version": "3.4.4", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/flru": { + "version": "1.0.2", + "integrity": "sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==", "dev": true, "license": "MIT", - "peer": true + "engines": { + "node": ">=6" + } }, - "node_modules/http-proxy": { - "version": "1.18.1", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "node_modules/for-each": { + "version": "0.3.5", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" + "is-callable": "^1.2.7" }, "engines": { - "node": ">=8.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "node_modules/foreground-child": { + "version": "3.3.1", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" }, "engines": { - "node": ">= 14" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/http-proxy-agent/node_modules/agent-base": { - "version": "7.1.4", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "node_modules/forwarded": { + "version": "0.2.0", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", "dev": true, "license": "MIT", "engines": { - "node": ">= 14" + "node": ">= 0.6" } }, - "node_modules/http-proxy-middleware": { - "version": "4.2.0", - "integrity": "sha512-ZA+oNOoM+GLoFTIzhkJptVQov73Srep2LBqhF8hG8CIPKO3nam1jonXVQ/QUH8RbwsmaaVz2SOJdzBNBHNtKbw==", + "node_modules/fraction.js": { + "version": "5.3.4", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", "dev": true, "license": "MIT", "peer": true, - "dependencies": { - "debug": "^4.4.3", - "httpxy": "^0.5.4", - "is-glob": "^4.0.3", - "is-plain-obj": "^4.1.0", - "micromatch": "^4.0.8" - }, "engines": { - "node": "^22.15.0 || ^24.0.0 || >=26.0.0" + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" } }, - "node_modules/https-proxy-agent": { - "version": "9.1.0", - "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "node_modules/fresh": { + "version": "2.0.0", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "dev": true, "license": "MIT", - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4", - "proxy-agent-negotiate": "1.1.0" - }, "engines": { - "node": ">= 20" + "node": ">= 0.8" } }, - "node_modules/httpxy": { - "version": "0.5.5", - "integrity": "sha512-uDjmnPyp1q4Sgzf3w+J/Fc6UqcCEj0x4Wjp7OqK5dGhNeDgpyrAmnS6ey8QWrX3SWDon2DMKf9sBa5X9+CVyMA==", + "node_modules/fs.realpath": { + "version": "1.0.0", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true, - "license": "MIT", - "peer": true + "license": "ISC" }, - "node_modules/human-signals": { - "version": "2.1.0", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "node_modules/fsevents": { + "version": "2.3.3", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, - "license": "Apache-2.0", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=10.17.0" + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/hyperdyperid": { - "version": "1.2.0", - "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "node_modules/function-bind": { + "version": "1.1.2", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.18" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "node_modules/function.prototype.name": { + "version": "1.2.0", + "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==", "dev": true, "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2", + "hasown": "^2.0.4", + "is-callable": "^1.2.7", + "is-document.all": "^1.0.0" }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/icss-utils": { - "version": "5.1.0", - "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "node_modules/functions-have-names": { + "version": "1.2.3", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true, - "license": "ISC", - "peer": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" + "node": ">= 0.4" } }, - "node_modules/ignore": { - "version": "7.0.9", - "integrity": "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==", + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", "engines": { - "node": ">= 4" + "node": ">=6.9.0" } }, - "node_modules/immutable": { - "version": "5.1.9", - "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", + "node_modules/get-caller-file": { + "version": "2.0.5", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, - "license": "MIT" + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "node_modules/get-east-asian-width": { + "version": "1.7.0", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", "dev": true, "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, "engines": { - "node": ">=6" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/import-local": { - "version": "3.2.0", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "node_modules/get-intrinsic": { + "version": "1.3.0", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { - "node": ">=8" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/import-meta-resolve": { - "version": "4.2.0", - "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "node_modules/get-package-type": { + "version": "0.1.0", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true, "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" + "engines": { + "node": ">=8.0.0" } }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "node_modules/get-proto": { + "version": "1.0.1", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, "engines": { - "node": ">=0.8.19" + "node": ">= 0.4" } }, - "node_modules/inflight": { - "version": "1.0.6", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "node_modules/get-stream": { + "version": "6.0.1", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/inherits": { - "version": "2.0.4", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "node_modules/get-symbol-description": { + "version": "1.1.0", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "dev": true, - "license": "ISC" + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, - "node_modules/injection-js": { - "version": "2.6.1", - "integrity": "sha512-dbR5bdhi7TWDoCye9cByZqeg/gAfamm8Vu3G1KZOTYkOif8WkuM8CD0oeDPtZYMzT5YH76JAFB7bkmyY9OJi2A==", + "node_modules/get-tsconfig": { + "version": "4.14.3", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", "dev": true, "license": "MIT", "dependencies": { - "tslib": "^2.0.0" + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/internal-slot": { - "version": "1.1.0", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "node_modules/glob": { + "version": "13.0.6", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": ">= 0.4" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/ip-address": { - "version": "10.7.2", - "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "node_modules/glob-parent": { + "version": "6.0.2", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, "engines": { - "node": ">= 12" + "node": ">=10.13.0" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "node_modules/glob-to-regex.js": { + "version": "1.3.1", + "integrity": "sha512-zhWhMRsgnOym+1txhUmYJGD6eUsbUdrZv55gCYKuJX8t2p3+M8My23PD09bEIsreGUs5pnSa/idmcfp2/IlqGg==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", + "peer": true, "engines": { - "node": ">= 0.10" + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "node_modules/globals": { + "version": "13.24.0", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" + "type-fest": "^0.20.2" }, "engines": { - "node": ">= 0.4" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-async-function": { - "version": "2.1.1", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "node_modules/globalthis": { + "version": "1.0.4", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, "license": "MIT", "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" + "define-properties": "^1.2.1", + "gopd": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -13184,14 +12578,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-bigint": { - "version": "1.1.0", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "node_modules/gopd": { + "version": "1.2.0", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, "engines": { "node": ">= 0.4" }, @@ -13199,54 +12590,51 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "node_modules/graceful-fs": { + "version": "4.2.11", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } + "license": "ISC" }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "node_modules/graphemer": { + "version": "1.4.0", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" }, "engines": { - "node": ">= 0.4" + "node": ">=0.4.7" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "optionalDependencies": { + "uglify-js": "^3.1.4" } }, - "node_modules/is-builtin-module": { - "version": "3.2.1", - "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "builtin-modules": "^3.3.0" - }, + "license": "BSD-3-Clause", "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=0.10.0" } }, - "node_modules/is-callable": { - "version": "1.2.7", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "node_modules/has-bigints": { + "version": "1.1.0", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "dev": true, "license": "MIT", "engines": { @@ -13256,30 +12644,34 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-core-module": { - "version": "2.17.0", - "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "node_modules/has-flag": { + "version": "4.0.0", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", - "dependencies": { - "hasown": "^2.0.4" - }, "engines": { - "node": ">= 0.4" + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-data-view": { - "version": "1.0.2", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "node_modules/has-proto": { + "version": "1.2.0", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" + "dunder-proto": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -13288,15 +12680,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-date-object": { + "node_modules/has-symbols": { "version": "1.1.0", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, "engines": { "node": ">= 0.4" }, @@ -13304,307 +12692,367 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-docker": { - "version": "3.0.0", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "license": "MIT", - "peer": true, - "bin": { - "is-docker": "cli.js" + "dependencies": { + "has-symbols": "^1.0.3" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-document.all": { - "version": "1.0.0", - "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", + "node_modules/hasown": { + "version": "2.0.4", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.4" + "function-bind": "^1.1.2" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/highlight.js": { + "version": "11.12.0", + "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/hono": { + "version": "4.13.12", + "integrity": "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=16.9.0" } }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "node_modules/hosted-git-info": { + "version": "10.1.1", + "integrity": "sha512-DeOnSPAvOndYKfw075gt8yZzQ7S2hNztw34zBTfhIzLhmBTswIBg5/y+pqu/VD5cYWm5goAFTusDmUEmKZ0PEQ==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "call-bound": "^1.0.3" + "lru-cache": "^11.1.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "get-east-asian-width": "^1.3.1" + "@exodus/bytes": "^1.15.1" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^22.13.0 || >=24.0.0" } }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "node_modules/html-escaper": { + "version": "2.0.2", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "12.0.0", + "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==", "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "domutils": "^4.0.2", + "entities": "^8.0.0" + }, "engines": { - "node": ">=6" + "node": ">=20.19.0" } }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "node_modules/http-errors": { + "version": "2.0.1", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, "engines": { - "node": ">= 0.4" + "node": ">= 0.8" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "agent-base": "^7.1.0", + "debug": "^4.3.4" }, "engines": { - "node": ">=0.10.0" + "node": ">= 14" } }, - "node_modules/is-in-ssh": { - "version": "1.0.0", - "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "7.1.4", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=20" + "node": ">= 14" + } + }, + "node_modules/http-proxy-middleware": { + "version": "4.2.0", + "integrity": "sha512-ZA+oNOoM+GLoFTIzhkJptVQov73Srep2LBqhF8hG8CIPKO3nam1jonXVQ/QUH8RbwsmaaVz2SOJdzBNBHNtKbw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "debug": "^4.4.3", + "httpxy": "^0.5.4", + "is-glob": "^4.0.3", + "is-plain-obj": "^4.1.0", + "micromatch": "^4.0.8" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": "^22.15.0 || ^24.0.0 || >=26.0.0" } }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "node_modules/https-proxy-agent": { + "version": "9.1.0", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" }, "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 20" } }, - "node_modules/is-interactive": { - "version": "2.0.0", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "node_modules/httpxy": { + "version": "0.5.5", + "integrity": "sha512-uDjmnPyp1q4Sgzf3w+J/Fc6UqcCEj0x4Wjp7OqK5dGhNeDgpyrAmnS6ey8QWrX3SWDon2DMKf9sBa5X9+CVyMA==", "dev": true, "license": "MIT", + "peer": true + }, + "node_modules/human-signals": { + "version": "2.1.0", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=10.17.0" } }, - "node_modules/is-map": { - "version": "2.0.3", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "node_modules/hyperdyperid": { + "version": "1.2.0", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=10.18" } }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "node_modules/iconv-lite": { + "version": "0.7.3", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "dev": true, "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, "engines": { - "node": ">= 0.4" + "node": ">=0.10.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/is-network-error": { - "version": "1.3.2", - "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", + "node_modules/icss-utils": { + "version": "5.1.0", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", "dev": true, - "license": "MIT", + "license": "ISC", "peer": true, "engines": { - "node": ">=16" + "node": "^10 || ^12 || >= 14" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "postcss": "^8.1.0" } }, - "node_modules/is-number": { - "version": "7.0.0", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "node_modules/ignore": { + "version": "7.0.10", + "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=0.12.0" + "node": ">= 4" } }, - "node_modules/is-number-object": { - "version": "1.1.1", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "node_modules/immutable": { + "version": "5.1.9", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=6" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "node_modules/import-local": { + "version": "3.2.0", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", "dev": true, "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-plain-obj": { - "version": "4.1.0", - "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "node_modules/imurmurhash": { + "version": "0.1.4", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "isobject": "^3.0.1" - }, "engines": { - "node": ">=0.10.0" + "node": ">=0.8.19" } }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "node_modules/inflight": { + "version": "1.0.6", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, - "license": "MIT" + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } }, - "node_modules/is-promise": { - "version": "4.0.0", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "node_modules/inherits": { + "version": "2.0.4", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true, - "license": "MIT" + "license": "ISC" }, - "node_modules/is-regex": { - "version": "1.2.1", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "node_modules/injection-js": { + "version": "2.6.1", + "integrity": "sha512-dbR5bdhi7TWDoCye9cByZqeg/gAfamm8Vu3G1KZOTYkOif8WkuM8CD0oeDPtZYMzT5YH76JAFB7bkmyY9OJi2A==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "tslib": "^2.0.0" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-set": { - "version": "2.0.3", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "node_modules/ip-address": { + "version": "10.7.2", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 12" } }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "node_modules/ipaddr.js": { + "version": "1.9.1", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3" + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -13613,26 +13061,23 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-stream": { - "version": "2.0.1", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "node_modules/is-arrayish": { + "version": "0.2.1", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "license": "MIT" }, - "node_modules/is-string": { - "version": "1.1.1", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "node_modules/is-async-function": { + "version": "2.1.1", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "dev": true, "license": "MIT", "dependencies": { + "async-function": "^1.0.0", "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -13641,15 +13086,13 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-symbol": { - "version": "1.1.1", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "node_modules/is-bigint": { + "version": "1.1.0", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" + "dependencies": { + "has-bigints": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -13658,13 +13101,14 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "node_modules/is-boolean-object": { + "version": "1.2.2", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "dev": true, "license": "MIT", "dependencies": { - "which-typed-array": "^1.1.16" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -13673,21 +13117,25 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", + "node_modules/is-builtin-module": { + "version": "3.2.1", + "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "builtin-modules": "^3.3.0" + }, "engines": { - "node": ">=18" + "node": ">=6" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "node_modules/is-callable": { + "version": "1.2.7", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true, "license": "MIT", "engines": { @@ -13697,13 +13145,13 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-weakref": { - "version": "1.1.1", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "node_modules/is-core-module": { + "version": "2.17.0", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3" + "hasown": "^2.0.4" }, "engines": { "node": ">= 0.4" @@ -13712,14 +13160,15 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-weakset": { - "version": "2.0.4", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "node_modules/is-data-view": { + "version": "1.0.2", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -13728,641 +13177,522 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-what": { - "version": "4.1.16", - "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", + "node_modules/is-date-object": { + "version": "1.1.0", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, "engines": { - "node": ">=12.13" + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/mesqueeb" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-wsl": { - "version": "3.1.1", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "node_modules/is-docker": { + "version": "3.0.0", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", "dev": true, "license": "MIT", "peer": true, - "dependencies": { - "is-inside-container": "^1.0.0" + "bin": { + "is-docker": "cli.js" }, "engines": { - "node": ">=16" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/isarray": { - "version": "2.0.5", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/isobject": { - "version": "3.0.1", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "node_modules/is-document.all": { + "version": "1.0.0", + "integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==", "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" + "call-bound": "^1.0.4" }, "engines": { - "node": ">=10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/code-frame": { - "version": "7.29.7", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "node_modules/is-extglob": { + "version": "2.1.1", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, "engines": { - "node": ">=6.9.0" + "node": ">=0.10.0" } }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/compat-data": { - "version": "7.29.7", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, "engines": { - "node": ">=6.9.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/core": { - "version": "7.29.7", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" + "get-east-asian-width": "^1.3.1" }, "engines": { - "node": ">=6.9.0" + "node": ">=18" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/is-generator-fn": { + "version": "2.1.0", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "engines": { + "node": ">=6" } }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/generator": { - "version": "7.29.8", - "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "node_modules/is-generator-function": { + "version": "1.1.2", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { - "node": ">=6.9.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "node_modules/is-glob": { + "version": "4.0.3", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" + "is-extglob": "^2.1.1" }, "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node": ">=0.10.0" } }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-globals": { - "version": "7.29.7", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "node_modules/is-in-ssh": { + "version": "1.0.0", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "node_modules/is-inside-container": { + "version": "1.0.0", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" }, "engines": { - "node": ">=6.9.0" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "node_modules/is-interactive": { + "version": "2.0.0", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, "engines": { - "node": ">=6.9.0" + "node": ">=12" }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "node_modules/is-map": { + "version": "2.0.3", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "dev": true, "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "node_modules/is-negative-zero": { + "version": "2.0.3", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "dev": true, "license": "MIT", "engines": { - "node": ">=6.9.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "node_modules/is-network-error": { + "version": "1.3.2", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/helpers": { - "version": "7.29.7", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "node_modules/is-number": { + "version": "7.0.0", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=0.12.0" } }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/parser": { - "version": "7.29.9", - "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "node_modules/is-number-object": { + "version": "1.1.1", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, "engines": { - "node": ">=6.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/template": { - "version": "7.29.7", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "node_modules/is-path-inside": { + "version": "3.0.3", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, "engines": { - "node": ">=6.9.0" + "node": ">=8" } }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/traverse": { - "version": "7.29.8", - "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "node_modules/is-plain-obj": { + "version": "4.1.0", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.8", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.8", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", - "debug": "^4.3.1" - }, + "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/istanbul-lib-instrument/node_modules/@babel/types": { - "version": "7.29.8", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "node_modules/is-plain-object": { + "version": "2.0.4", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" + "isobject": "^3.0.1" }, "engines": { - "node": ">=6.9.0" + "node": ">=0.10.0" } }, - "node_modules/istanbul-lib-instrument/node_modules/convert-source-map": { - "version": "2.0.0", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true, "license": "MIT" }, - "node_modules/istanbul-lib-instrument/node_modules/js-tokens": { + "node_modules/is-promise": { "version": "4.0.0", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "dev": true, "license": "MIT" }, - "node_modules/istanbul-lib-instrument/node_modules/lru-cache": { - "version": "5.1.1", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "node_modules/is-regex": { + "version": "1.2.1", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "yallist": "^3.0.2" + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "node_modules/is-set": { + "version": "2.0.3", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" + "call-bound": "^1.0.3" }, "engines": { - "node": ">=10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "node_modules/is-stream": { + "version": "2.0.1", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, + "license": "MIT", "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "node_modules/is-string": { + "version": "1.1.1", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "@isaacs/cliui": "^8.0.2" + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "engines": { + "node": ">= 0.4" }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest": { - "version": "30.5.2", - "integrity": "sha512-3gnpdBIza8ijCl3iMV9ChE3hSt1+46865qqod863gQtJr1DixOkAU5DoGbi3u+XlRcw++BhZQMu/y4xBdHBvSQ==", + "node_modules/is-symbol": { + "version": "1.1.1", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.5.2", - "@jest/types": "30.5.1", - "import-local": "^3.2.0", - "jest-cli": "30.5.2" - }, - "bin": { - "jest": "bin/jest.js" + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-changed-files": { - "version": "30.5.1", - "integrity": "sha512-0+bvMM/ENhDI29Z8q1r4HxiDIi4G5tnBmSw4esfPQoj9q8Nik7KIyuxHkTVnnniJQf05SxpGaKDQ+4h28ynGPg==", + "node_modules/is-typed-array": { + "version": "1.1.15", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, "license": "MIT", "dependencies": { - "execa": "^5.1.1", - "jest-util": "30.5.1", - "p-limit": "^3.1.0" + "which-typed-array": "^1.1.16" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-circus": { - "version": "30.5.2", - "integrity": "sha512-vffFgjs5JSJ9q4ds1vNW3klKYPJfYfIY61LT/zaZgvTeASJOWZUt8LBfHgIcV4rwxlPBLl/ePBGccHEclS4PlQ==", + "node_modules/is-unicode-supported": { + "version": "2.1.0", + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/environment": "30.5.2", - "@jest/expect": "30.5.2", - "@jest/test-result": "30.5.2", - "@jest/types": "30.5.1", - "@types/node": "*", - "chalk": "^4.1.2", - "co": "^4.6.0", - "dedent": "^1.6.0", - "is-generator-fn": "^2.1.0", - "jest-each": "30.5.2", - "jest-matcher-utils": "30.5.2", - "jest-message-util": "30.5.1", - "jest-runtime": "30.5.2", - "jest-snapshot": "30.5.2", - "jest-util": "30.5.1", - "p-limit": "^3.1.0", - "pretty-format": "30.5.1", - "pure-rand": "^7.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-cli": { - "version": "30.5.2", - "integrity": "sha512-YYYxUUVjFgPvgEleKNKMaYGIFgR3l3832Cl0kRL/oCiDZ03v7wImUtquHl2OOBCCNgprjZgBN5bczS/JklPBDg==", + "node_modules/is-weakmap": { + "version": "2.0.2", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/core": "30.5.2", - "@jest/test-result": "30.5.2", - "@jest/types": "30.5.1", - "chalk": "^4.1.2", - "exit-x": "^0.2.2", - "import-local": "^3.2.0", - "jest-config": "30.5.2", - "jest-util": "30.5.1", - "jest-validate": "30.5.1", - "yargs": "^17.7.2" - }, - "bin": { - "jest": "bin/jest.js" - }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + "node": ">= 0.4" }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-cli/node_modules/cliui": { - "version": "8.0.1", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "node_modules/is-weakref": { + "version": "1.1.1", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "dev": true, - "license": "ISC", + "license": "MIT", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "call-bound": "^1.0.3" }, "engines": { - "node": ">=12" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-cli/node_modules/emoji-regex": { - "version": "8.0.0", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-cli/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/is-weakset": { + "version": "2.0.4", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dev": true, "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/jest-cli/node_modules/string-width": { - "version": "4.2.3", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/is-what": { + "version": "4.1.16", + "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", "dev": true, "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, "engines": { - "node": ">=8" + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" } }, - "node_modules/jest-cli/node_modules/wrap-ansi": { - "version": "7.0.0", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "node_modules/is-wsl": { + "version": "3.1.1", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "is-inside-container": "^1.0.0" }, "engines": { - "node": ">=10" + "node": ">=16" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/jest-cli/node_modules/yargs": { - "version": "17.7.3", - "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "node_modules/isarray": { + "version": "2.0.5", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", "dev": true, "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, + "peer": true, "engines": { - "node": ">=12" + "node": ">=0.10.0" } }, - "node_modules/jest-cli/node_modules/yargs-parser": { - "version": "21.1.1", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", "dev": true, - "license": "ISC", + "license": "BSD-3-Clause", "engines": { - "node": ">=12" + "node": ">=8" } }, - "node_modules/jest-config": { - "version": "30.5.2", - "integrity": "sha512-U6221OZekabePvGcGPf4raIBKMvYQWFHvhobDJRojOYaFMj2HaGytAdknjDhYc6JTz2fdHW9+6k0wWUDaNeOFQ==", + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", "dependencies": { - "@babel/core": "^7.27.4", - "@jest/get-type": "30.5.0", - "@jest/pattern": "30.5.0", - "@jest/test-sequencer": "30.5.2", - "@jest/types": "30.5.1", - "babel-jest": "30.5.2", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "deepmerge": "^4.3.1", - "glob": "^13.0.6", - "graceful-fs": "^4.2.11", - "jest-circus": "30.5.2", - "jest-docblock": "30.5.0", - "jest-environment-node": "30.5.2", - "jest-regex-util": "30.5.0", - "jest-resolve": "30.5.1", - "jest-runner": "30.5.2", - "jest-util": "30.5.1", - "jest-validate": "30.5.1", - "parse-json": "^5.2.0", - "pretty-format": "30.5.1", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "esbuild-register": ">=3.4.0", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "esbuild-register": { - "optional": true - }, - "ts-node": { - "optional": true - } + "node": ">=10" } }, - "node_modules/jest-config/node_modules/@babel/code-frame": { + "node_modules/istanbul-lib-instrument/node_modules/@babel/code-frame": { "version": "7.29.7", "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, @@ -14376,7 +13706,7 @@ "node": ">=6.9.0" } }, - "node_modules/jest-config/node_modules/@babel/compat-data": { + "node_modules/istanbul-lib-instrument/node_modules/@babel/compat-data": { "version": "7.29.7", "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, @@ -14385,7 +13715,7 @@ "node": ">=6.9.0" } }, - "node_modules/jest-config/node_modules/@babel/core": { + "node_modules/istanbul-lib-instrument/node_modules/@babel/core": { "version": "7.29.7", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, @@ -14415,7 +13745,16 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/jest-config/node_modules/@babel/generator": { + "node_modules/istanbul-lib-instrument/node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/generator": { "version": "7.29.8", "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, @@ -14431,7 +13770,7 @@ "node": ">=6.9.0" } }, - "node_modules/jest-config/node_modules/@babel/helper-compilation-targets": { + "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-compilation-targets": { "version": "7.29.7", "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, @@ -14447,7 +13786,16 @@ "node": ">=6.9.0" } }, - "node_modules/jest-config/node_modules/@babel/helper-globals": { + "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-globals": { "version": "7.29.7", "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, @@ -14456,7 +13804,7 @@ "node": ">=6.9.0" } }, - "node_modules/jest-config/node_modules/@babel/helper-module-imports": { + "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-module-imports": { "version": "7.29.7", "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, @@ -14469,7 +13817,7 @@ "node": ">=6.9.0" } }, - "node_modules/jest-config/node_modules/@babel/helper-module-transforms": { + "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-module-transforms": { "version": "7.29.7", "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, @@ -14486,7 +13834,7 @@ "@babel/core": "^7.0.0" } }, - "node_modules/jest-config/node_modules/@babel/helper-string-parser": { + "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-string-parser": { "version": "7.29.7", "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, @@ -14495,7 +13843,7 @@ "node": ">=6.9.0" } }, - "node_modules/jest-config/node_modules/@babel/helper-validator-identifier": { + "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-validator-identifier": { "version": "7.29.7", "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, @@ -14504,7 +13852,7 @@ "node": ">=6.9.0" } }, - "node_modules/jest-config/node_modules/@babel/helper-validator-option": { + "node_modules/istanbul-lib-instrument/node_modules/@babel/helper-validator-option": { "version": "7.29.7", "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, @@ -14513,7 +13861,7 @@ "node": ">=6.9.0" } }, - "node_modules/jest-config/node_modules/@babel/helpers": { + "node_modules/istanbul-lib-instrument/node_modules/@babel/helpers": { "version": "7.29.7", "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, @@ -14526,7 +13874,7 @@ "node": ">=6.9.0" } }, - "node_modules/jest-config/node_modules/@babel/parser": { + "node_modules/istanbul-lib-instrument/node_modules/@babel/parser": { "version": "7.29.9", "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, @@ -14541,7 +13889,7 @@ "node": ">=6.0.0" } }, - "node_modules/jest-config/node_modules/@babel/template": { + "node_modules/istanbul-lib-instrument/node_modules/@babel/template": { "version": "7.29.7", "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, @@ -14555,7 +13903,7 @@ "node": ">=6.9.0" } }, - "node_modules/jest-config/node_modules/@babel/traverse": { + "node_modules/istanbul-lib-instrument/node_modules/@babel/traverse": { "version": "7.29.8", "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, @@ -14573,7 +13921,7 @@ "node": ">=6.9.0" } }, - "node_modules/jest-config/node_modules/@babel/types": { + "node_modules/istanbul-lib-instrument/node_modules/@babel/types": { "version": "7.29.8", "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, @@ -14586,19 +13934,19 @@ "node": ">=6.9.0" } }, - "node_modules/jest-config/node_modules/convert-source-map": { + "node_modules/istanbul-lib-instrument/node_modules/convert-source-map": { "version": "2.0.0", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, - "node_modules/jest-config/node_modules/js-tokens": { + "node_modules/istanbul-lib-instrument/node_modules/js-tokens": { "version": "4.0.0", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, "license": "MIT" }, - "node_modules/jest-config/node_modules/lru-cache": { + "node_modules/istanbul-lib-instrument/node_modules/lru-cache": { "version": "5.1.1", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, @@ -14607,778 +13955,944 @@ "yallist": "^3.0.2" } }, - "node_modules/jest-config/node_modules/semver": { - "version": "6.3.1", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, - "license": "ISC", + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.5.2", + "integrity": "sha512-3gnpdBIza8ijCl3iMV9ChE3hSt1+46865qqod863gQtJr1DixOkAU5DoGbi3u+XlRcw++BhZQMu/y4xBdHBvSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.5.2", + "@jest/types": "30.5.1", + "import-local": "^3.2.0", + "jest-cli": "30.5.2" + }, "bin": { - "semver": "bin/semver.js" + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/jest-diff": { + "node_modules/jest-changed-files": { + "version": "30.5.1", + "integrity": "sha512-0+bvMM/ENhDI29Z8q1r4HxiDIi4G5tnBmSw4esfPQoj9q8Nik7KIyuxHkTVnnniJQf05SxpGaKDQ+4h28ynGPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.5.1", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { "version": "30.5.2", - "integrity": "sha512-rBtHnI6BWTiWj3lM7fOLVfChx7R9B0u9VV3PRHawKs79x6ZjW1QoSrKenaigNGWlvhtoi2BqvDbABDie+Os3xQ==", + "integrity": "sha512-vffFgjs5JSJ9q4ds1vNW3klKYPJfYfIY61LT/zaZgvTeASJOWZUt8LBfHgIcV4rwxlPBLl/ePBGccHEclS4PlQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.5.0", - "@jest/get-type": "30.5.0", + "@jest/environment": "30.5.2", + "@jest/expect": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/types": "30.5.1", + "@types/node": "*", "chalk": "^4.1.2", - "pretty-format": "30.5.1" + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.5.2", + "jest-matcher-utils": "30.5.2", + "jest-message-util": "30.5.1", + "jest-runtime": "30.5.2", + "jest-snapshot": "30.5.2", + "jest-util": "30.5.1", + "p-limit": "^3.1.0", + "pretty-format": "30.5.1", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.5.2", + "integrity": "sha512-YYYxUUVjFgPvgEleKNKMaYGIFgR3l3832Cl0kRL/oCiDZ03v7wImUtquHl2OOBCCNgprjZgBN5bczS/JklPBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.5.2", + "@jest/test-result": "30.5.2", + "@jest/types": "30.5.1", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.5.2", + "jest-util": "30.5.1", + "jest-validate": "30.5.1", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-cli/node_modules/cliui": { + "version": "8.0.1", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jest-cli/node_modules/emoji-regex": { + "version": "8.0.0", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-cli/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-cli/node_modules/string-width": { + "version": "4.2.3", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-cli/node_modules/wrap-ansi": { + "version": "7.0.0", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/jest-docblock": { - "version": "30.5.0", - "integrity": "sha512-NwDqcxtoZi33RhuW+zJS/RVA3rmheQ8BnwpYZuc/Eruaz6seQb7+aoCeDZu/3X7W2XmD8DbSo9Pn72DbKsBFYw==", + "node_modules/jest-cli/node_modules/yargs": { + "version": "17.7.3", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", "dev": true, "license": "MIT", "dependencies": { - "detect-newline": "^3.1.0" + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=12" } }, - "node_modules/jest-each": { + "node_modules/jest-config": { "version": "30.5.2", - "integrity": "sha512-GRrW+7fmmgmkvyfEBqc1QFdWnF1Ex5yG6Y6AWL4TTxnkWlKIT98dsKh3P3UuWm6v4XsQwVE6DEzXIPMzWJ3ZbA==", + "integrity": "sha512-U6221OZekabePvGcGPf4raIBKMvYQWFHvhobDJRojOYaFMj2HaGytAdknjDhYc6JTz2fdHW9+6k0wWUDaNeOFQ==", "dev": true, "license": "MIT", "dependencies": { + "@babel/core": "^7.27.4", "@jest/get-type": "30.5.0", + "@jest/pattern": "30.5.0", + "@jest/test-sequencer": "30.5.2", "@jest/types": "30.5.1", + "babel-jest": "30.5.2", "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^13.0.6", + "graceful-fs": "^4.2.11", + "jest-circus": "30.5.2", + "jest-docblock": "30.5.0", + "jest-environment-node": "30.5.2", "jest-regex-util": "30.5.0", + "jest-resolve": "30.5.1", + "jest-runner": "30.5.2", "jest-util": "30.5.1", - "pretty-format": "30.5.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-environment-jsdom": { - "version": "30.5.2", - "integrity": "sha512-6NOZjEP+WNi4WXjbxb/1ROnfaMxn6Dhqu6uSC22o0H28z+uppejs/jZ9KCVoLepqqoCjcNZujtzQqC9jSV15jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.5.2", - "@jest/environment-jsdom-abstract": "30.5.2", - "@types/jsdom": "^21.1.7", - "jsdom": "^26.1.0" + "jest-validate": "30.5.1", + "parse-json": "^5.2.0", + "pretty-format": "30.5.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "canvas": "^3.0.0" + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" }, "peerDependenciesMeta": { - "canvas": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { "optional": true } } }, - "node_modules/jest-environment-jsdom/node_modules/agent-base": { - "version": "7.1.4", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "node_modules/jest-config/node_modules/@babel/code-frame": { + "version": "7.29.7", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, "engines": { - "node": ">= 14" + "node": ">=6.9.0" } }, - "node_modules/jest-environment-jsdom/node_modules/data-urls": { - "version": "5.0.0", - "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "node_modules/jest-config/node_modules/@babel/compat-data": { + "version": "7.29.7", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/jest-environment-jsdom/node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "node_modules/jest-config/node_modules/@babel/core": { + "version": "7.29.7", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "whatwg-encoding": "^3.1.1" + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" } }, - "node_modules/jest-environment-jsdom/node_modules/https-proxy-agent": { - "version": "7.0.6", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/jest-config/node_modules/@babel/generator": { + "version": "7.29.8", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" }, "engines": { - "node": ">= 14" + "node": ">=6.9.0" } }, - "node_modules/jest-environment-jsdom/node_modules/jsdom": { - "version": "26.1.0", - "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "node_modules/jest-config/node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "cssstyle": "^4.2.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.5.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.16", - "parse5": "^7.2.1", - "rrweb-cssom": "^0.8.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^5.1.1", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.1.1", - "ws": "^8.18.0", - "xml-name-validator": "^5.0.0" + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" }, "engines": { - "node": ">=18" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } + "node": ">=6.9.0" } }, - "node_modules/jest-environment-jsdom/node_modules/tldts": { - "version": "6.1.86", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "node_modules/jest-config/node_modules/@babel/helper-globals": { + "version": "7.29.7", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", - "dependencies": { - "tldts-core": "^6.1.86" - }, - "bin": { - "tldts": "bin/cli.js" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/jest-environment-jsdom/node_modules/tldts-core": { - "version": "6.1.86", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/jest-environment-jsdom/node_modules/tough-cookie": { - "version": "5.1.2", - "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "node_modules/jest-config/node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "tldts": "^6.1.32" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { - "node": ">=16" + "node": ">=6.9.0" } }, - "node_modules/jest-environment-jsdom/node_modules/tr46": { - "version": "5.1.1", - "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "node_modules/jest-config/node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "punycode": "^2.3.1" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { - "node": ">=18" - } - }, - "node_modules/jest-environment-jsdom/node_modules/webidl-conversions": { - "version": "7.0.0", - "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "node_modules/jest-environment-jsdom/node_modules/whatwg-mimetype": { - "version": "4.0.0", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "node_modules/jest-config/node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/jest-environment-jsdom/node_modules/whatwg-url": { - "version": "14.2.0", - "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "node_modules/jest-config/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "node_modules/jest-environment-node": { - "version": "30.5.2", - "integrity": "sha512-Ciz4KgTGIbojxSKpuFImgWJj5EyqfE8xMq9NxWmOXnQywHHS03H9Mv+vaCB29J5pr3JBrY27WbRV18rBdjh8Dg==", + "node_modules/jest-config/node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", - "dependencies": { - "@jest/environment": "30.5.2", - "@jest/fake-timers": "30.5.2", - "@jest/types": "30.5.1", - "@types/node": "*", - "jest-mock": "30.5.2", - "jest-util": "30.5.1", - "jest-validate": "30.5.1" - }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=6.9.0" } }, - "node_modules/jest-haste-map": { - "version": "30.5.1", - "integrity": "sha512-VIFgt67jW480YDxKfEv9IYQKrFpYt7bOCMn3VsnjK7AK9qo7p6acpnA1DHwsVOULE3dTaYew/6JaTD/d0VDHoQ==", + "node_modules/jest-config/node_modules/@babel/helpers": { + "version": "7.29.7", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.5.1", - "@parcel/watcher": "^2.6.0", - "@types/node": "*", - "anymatch": "^3.1.3", - "fb-watchman": "^2.0.2", - "fdir": "^6.5.0", - "graceful-fs": "^4.2.11", - "jest-regex-util": "30.5.0", - "jest-util": "30.5.1", - "jest-worker": "30.5.1", - "picomatch": "^4.0.3" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=6.9.0" } }, - "node_modules/jest-leak-detector": { - "version": "30.5.1", - "integrity": "sha512-gt4GT2aWgEoCNTcBe4rqS74xTIUJxi+UD9SNSu9aOk5LmTEd6fS5KSTmAKAfitCCktQHNN+upUuL6EkBfFbMDQ==", + "node_modules/jest-config/node_modules/@babel/parser": { + "version": "7.29.9", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.5.0", - "pretty-format": "30.5.1" + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=6.0.0" } }, - "node_modules/jest-matcher-utils": { - "version": "30.5.2", - "integrity": "sha512-lFkB365PTIHOhPzwrb9T6gvlDnPpOZ7/c3ewOKKB7bzztqjVrtlyRL2MkyfJXpyY2u2SKw001JKrTx14fBUFcQ==", + "node_modules/jest-config/node_modules/@babel/template": { + "version": "7.29.7", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/get-type": "30.5.0", - "chalk": "^4.1.2", - "jest-diff": "30.5.2", - "pretty-format": "30.5.1" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=6.9.0" } }, - "node_modules/jest-message-util": { - "version": "30.5.1", - "integrity": "sha512-UdQlLdd9wL/Ys7xRErckqwD6wPlSZYueosSWuHc1r2ztGLwlgPvtSJq2+BPEgaEY13WLvfFbmhTj8pba0Sd1jg==", + "node_modules/jest-config/node_modules/@babel/traverse": { + "version": "7.29.8", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.5.1", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "jest-util": "30.5.1", - "picomatch": "^4.0.3", - "pretty-format": "30.5.1", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" }, "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + "node": ">=6.9.0" } }, - "node_modules/jest-message-util/node_modules/@babel/code-frame": { - "version": "7.29.7", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "node_modules/jest-config/node_modules/@babel/types": { + "version": "7.29.8", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/jest-message-util/node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "node_modules/jest-config/node_modules/convert-source-map": { + "version": "2.0.0", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } + "license": "MIT" }, - "node_modules/jest-message-util/node_modules/js-tokens": { + "node_modules/jest-config/node_modules/js-tokens": { "version": "4.0.0", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, "license": "MIT" }, - "node_modules/jest-mock": { + "node_modules/jest-config/node_modules/lru-cache": { + "version": "5.1.1", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/jest-config/node_modules/semver": { + "version": "6.3.1", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/jest-diff": { "version": "30.5.2", - "integrity": "sha512-KTHb37Fhj8xY8qPFvTFaFJHcdsumQp+ExsUlgoX232Agmc706gagBs53+CYvulV7MVQo9AgXOUxsqzzVBoJlrA==", + "integrity": "sha512-rBtHnI6BWTiWj3lM7fOLVfChx7R9B0u9VV3PRHawKs79x6ZjW1QoSrKenaigNGWlvhtoi2BqvDbABDie+Os3xQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.5.2", - "@jest/types": "30.5.1", - "@types/node": "*", - "jest-util": "30.5.1" + "@jest/diff-sequences": "30.5.0", + "@jest/get-type": "30.5.0", + "chalk": "^4.1.2", + "pretty-format": "30.5.1" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-preset-angular": { - "version": "17.0.0", - "integrity": "sha512-2yAHkA1c5rSICGJVtLYYqPC5RDsvo4+i4CwWFHVXwv41cHNX7gCWeh074IuZ5mFw7Vwsr+i25EowCnGtKkxNWw==", + "node_modules/jest-docblock": { + "version": "30.5.0", + "integrity": "sha512-NwDqcxtoZi33RhuW+zJS/RVA3rmheQ8BnwpYZuc/Eruaz6seQb7+aoCeDZu/3X7W2XmD8DbSo9Pn72DbKsBFYw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment-jsdom-abstract": "^30.4.1", - "bs-logger": "^0.2.6", - "esbuild-wasm": ">=0.28.0", - "jest-util": "^30.4.1", - "pretty-format": "^30.4.1", - "ts-jest": "^29.4.11" + "detect-newline": "^3.1.0" }, "engines": { - "node": "^20.11.1 || >=22.0.0" - }, - "optionalDependencies": { - "esbuild": ">=0.28.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.5.2", + "integrity": "sha512-GRrW+7fmmgmkvyfEBqc1QFdWnF1Ex5yG6Y6AWL4TTxnkWlKIT98dsKh3P3UuWm6v4XsQwVE6DEzXIPMzWJ3ZbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0", + "@jest/types": "30.5.1", + "chalk": "^4.1.2", + "jest-regex-util": "30.5.0", + "jest-util": "30.5.1", + "pretty-format": "30.5.1" }, - "peerDependencies": { - "@angular/compiler-cli": ">=20.0.0 <23.0.0", - "@angular/core": ">=20.0.0 <23.0.0", - "@angular/platform-browser": ">=20.0.0 <23.0.0", - "jest": "^30.0.0", - "jsdom": ">=26.0.0", - "typescript": ">=5.8" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-preset-angular/node_modules/@babel/code-frame": { - "version": "7.29.7", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "node_modules/jest-environment-jsdom": { + "version": "30.5.2", + "integrity": "sha512-6NOZjEP+WNi4WXjbxb/1ROnfaMxn6Dhqu6uSC22o0H28z+uppejs/jZ9KCVoLepqqoCjcNZujtzQqC9jSV15jg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" + "@jest/environment": "30.5.2", + "@jest/environment-jsdom-abstract": "30.5.2", + "@types/jsdom": "^21.1.7", + "jsdom": "^26.1.0" }, "engines": { - "node": ">=6.9.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, - "node_modules/jest-preset-angular/node_modules/@babel/compat-data": { - "version": "7.29.7", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "node_modules/jest-environment-jsdom/node_modules/agent-base": { + "version": "7.1.4", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">= 14" } }, - "node_modules/jest-preset-angular/node_modules/@babel/core": { - "version": "7.29.7", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "node_modules/jest-environment-jsdom/node_modules/data-urls": { + "version": "5.0.0", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" }, "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" + "node": ">=18" } }, - "node_modules/jest-preset-angular/node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/jest-environment-jsdom/node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver.js" + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" } }, - "node_modules/jest-preset-angular/node_modules/@babel/generator": { - "version": "7.29.8", - "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "node_modules/jest-environment-jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" + "agent-base": "^7.1.2", + "debug": "4" }, "engines": { - "node": ">=6.9.0" + "node": ">= 14" } }, - "node_modules/jest-preset-angular/node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "node_modules/jest-environment-jsdom/node_modules/jsdom": { + "version": "26.1.0", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=6.9.0" + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, - "node_modules/jest-preset-angular/node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/jest-environment-jsdom/node_modules/tldts": { + "version": "6.1.86", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", "dev": true, - "license": "ISC", - "optional": true, - "peer": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, "bin": { - "semver": "bin/semver.js" + "tldts": "bin/cli.js" } }, - "node_modules/jest-preset-angular/node_modules/@babel/helper-globals": { - "version": "7.29.7", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "node_modules/jest-environment-jsdom/node_modules/tldts-core": { + "version": "6.1.86", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true, + "license": "MIT" + }, + "node_modules/jest-environment-jsdom/node_modules/tough-cookie": { + "version": "5.1.2", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, "engines": { - "node": ">=6.9.0" + "node": ">=16" } }, - "node_modules/jest-preset-angular/node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "node_modules/jest-environment-jsdom/node_modules/tr46": { + "version": "5.1.1", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" + "punycode": "^2.3.1" }, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/jest-preset-angular/node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "node_modules/jest-environment-jsdom/node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" + "xml-name-validator": "^5.0.0" }, "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "node": ">=18" } }, - "node_modules/jest-preset-angular/node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "node_modules/jest-environment-jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true, + "license": "BSD-2-Clause", "engines": { - "node": ">=6.9.0" + "node": ">=12" } }, - "node_modules/jest-preset-angular/node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "node_modules/jest-environment-jsdom/node_modules/whatwg-mimetype": { + "version": "4.0.0", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/jest-preset-angular/node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "node_modules/jest-environment-jsdom/node_modules/whatwg-url": { + "version": "14.2.0", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/jest-preset-angular/node_modules/@babel/helpers": { - "version": "7.29.7", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "node_modules/jest-environment-node": { + "version": "30.5.2", + "integrity": "sha512-Ciz4KgTGIbojxSKpuFImgWJj5EyqfE8xMq9NxWmOXnQywHHS03H9Mv+vaCB29J5pr3JBrY27WbRV18rBdjh8Dg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" + "@jest/environment": "30.5.2", + "@jest/fake-timers": "30.5.2", + "@jest/types": "30.5.1", + "@types/node": "*", + "jest-mock": "30.5.2", + "jest-util": "30.5.1", + "jest-validate": "30.5.1" }, "engines": { - "node": ">=6.9.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-preset-angular/node_modules/@babel/parser": { - "version": "7.29.9", - "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "node_modules/jest-haste-map": { + "version": "30.5.1", + "integrity": "sha512-VIFgt67jW480YDxKfEv9IYQKrFpYt7bOCMn3VsnjK7AK9qo7p6acpnA1DHwsVOULE3dTaYew/6JaTD/d0VDHoQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "@babel/types": "^7.29.8" + "@jest/types": "30.5.1", + "@parcel/watcher": "^2.6.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "fdir": "^6.5.0", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.5.0", + "jest-util": "30.5.1", + "jest-worker": "30.5.1", + "picomatch": "^4.0.3" }, - "bin": { - "parser": "bin/babel-parser.js" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.5.1", + "integrity": "sha512-gt4GT2aWgEoCNTcBe4rqS74xTIUJxi+UD9SNSu9aOk5LmTEd6fS5KSTmAKAfitCCktQHNN+upUuL6EkBfFbMDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.5.0", + "pretty-format": "30.5.1" }, "engines": { - "node": ">=6.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-preset-angular/node_modules/@babel/template": { - "version": "7.29.7", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "node_modules/jest-matcher-utils": { + "version": "30.5.2", + "integrity": "sha512-lFkB365PTIHOhPzwrb9T6gvlDnPpOZ7/c3ewOKKB7bzztqjVrtlyRL2MkyfJXpyY2u2SKw001JKrTx14fBUFcQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" + "@jest/get-type": "30.5.0", + "chalk": "^4.1.2", + "jest-diff": "30.5.2", + "pretty-format": "30.5.1" }, "engines": { - "node": ">=6.9.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-preset-angular/node_modules/@babel/traverse": { - "version": "7.29.8", - "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "node_modules/jest-message-util": { + "version": "30.5.1", + "integrity": "sha512-UdQlLdd9wL/Ys7xRErckqwD6wPlSZYueosSWuHc1r2ztGLwlgPvtSJq2+BPEgaEY13WLvfFbmhTj8pba0Sd1jg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.8", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.8", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", - "debug": "^4.3.1" + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.5.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-util": "30.5.1", + "picomatch": "^4.0.3", + "pretty-format": "30.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" }, "engines": { - "node": ">=6.9.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-preset-angular/node_modules/@babel/types": { - "version": "7.29.8", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "node_modules/jest-message-util/node_modules/@babel/code-frame": { + "version": "7.29.7", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/jest-preset-angular/node_modules/convert-source-map": { - "version": "2.0.0", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "node_modules/jest-message-util/node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "engines": { + "node": ">=6.9.0" + } }, - "node_modules/jest-preset-angular/node_modules/js-tokens": { + "node_modules/jest-message-util/node_modules/js-tokens": { "version": "4.0.0", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, - "node_modules/jest-preset-angular/node_modules/lru-cache": { - "version": "5.1.1", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "node_modules/jest-mock": { + "version": "30.5.2", + "integrity": "sha512-KTHb37Fhj8xY8qPFvTFaFJHcdsumQp+ExsUlgoX232Agmc706gagBs53+CYvulV7MVQo9AgXOUxsqzzVBoJlrA==", "dev": true, - "license": "ISC", - "optional": true, - "peer": true, + "license": "MIT", "dependencies": { - "yallist": "^3.0.2" + "@jest/expect-utils": "30.5.2", + "@jest/types": "30.5.1", + "@types/node": "*", + "jest-util": "30.5.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-preset-angular/node_modules/ts-jest": { - "version": "29.4.12", - "integrity": "sha512-Ov6ClY53Fflh6BGAnY2DlTq1hYDrTycz2PVTXBWFW2CU+9zrEqAp9fWdGXl42EXO5RLSFAcAZ2JFKbP+zBTFfw==", + "node_modules/jest-preset-angular": { + "version": "17.0.1", + "integrity": "sha512-NQYAMn1fH+jvICCuK+g4MDW2S4zGj22XV0jzbzj8PPnWw0EmTCs0/oBJHmTrIWTV0QroT4vk0aquQgO9W50zEg==", "dev": true, "license": "MIT", "dependencies": { + "@jest/environment-jsdom-abstract": "^30.5.1", "bs-logger": "^0.2.6", - "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.9", - "json5": "^2.2.3", - "lodash.memoize": "^4.1.2", - "make-error": "^1.3.6", - "semver": "^7.8.5", - "type-fest": "^4.41.0", - "yargs-parser": "^21.1.1" - }, - "bin": { - "ts-jest": "cli.js" + "esbuild-wasm": ">=0.28.0", + "jest-util": "^30.5.1", + "pretty-format": "^30.5.1", + "ts-jest": "^29.4.12" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/transform": "^29.0.0 || ^30.0.0", - "@jest/types": "^29.0.0 || ^30.0.0", - "babel-jest": "^29.0.0 || ^30.0.0", - "jest": "^29.0.0 || ^30.0.0", - "jest-util": "^29.0.0 || ^30.0.0", - "typescript": ">=4.3 <7" + "node": "^20.11.1 || >=22.0.0" }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "@jest/transform": { - "optional": true - }, - "@jest/types": { - "optional": true - }, - "babel-jest": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jest-util": { - "optional": true - } - } - }, - "node_modules/jest-preset-angular/node_modules/type-fest": { - "version": "4.41.0", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" + "optionalDependencies": { + "esbuild": ">=0.28.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-preset-angular/node_modules/yargs-parser": { - "version": "21.1.1", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" + "peerDependencies": { + "@angular/compiler-cli": ">=20.0.0 <23.0.0", + "@angular/core": ">=20.0.0 <23.0.0", + "@angular/platform-browser": ">=20.0.0 <23.0.0", + "jest": "^30.0.0", + "jsdom": ">=26.0.0", + "typescript": ">=5.8" } }, "node_modules/jest-regex-util": { @@ -15965,28 +15479,28 @@ } }, "node_modules/jsdom": { - "version": "30.1.0", - "integrity": "sha512-h/Q+H+KmlGf49ITw3TrykSVbg04S75S2O7ZtJc9gMGOWBrQRZmx5AJIKLtJ01e6Kszl+/tpFthCVqxNifxhoqg==", + "version": "30.1.1", + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "@asamuzakjp/css-color": "^7.0.0", - "@asamuzakjp/dom-selector": "^9.1.2", + "@asamuzakjp/dom-selector": "^9.2.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.13", "@exodus/bytes": "^1.15.1", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", + "html-encoding-sniffer": "^7.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.2", "parse5": "^8.0.1", "saxes": "^6.0.0", "tough-cookie": "^6.0.2", "undici": "^8.10.2", - "w3c-xmlserializer": "^5.0.0", + "w3c-xmlserializer": "^6.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^17.1.1", @@ -16004,19 +15518,6 @@ } } }, - "node_modules/jsdom/node_modules/entities": { - "version": "8.1.0", - "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/jsdom/node_modules/parse5": { "version": "8.0.1", "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", @@ -16131,8 +15632,8 @@ } }, "node_modules/less": { - "version": "4.9.0", - "integrity": "sha512-umRhrCH7fCi8Uj2RcwKjJdvUORTjeWqkdKx0LbcZvjIwsAVsnIAGcxHaqowPeBFBjQuWOeC/bve0AlpFzF/+SQ==", + "version": "4.9.1", + "integrity": "sha512-orp15PfJvvNDIqJdVWzMI9Sjpjp3VTiw3sfvbB+67LlISTEn8uVT2EdYSuyl02BLvaftv6sdk9Umnxmm5rckmg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -16523,14 +16024,14 @@ } }, "node_modules/listr2": { - "version": "11.0.0", - "integrity": "sha512-8K88S0aSrcSXdJfiZtEy5BQMnR+TyjrCGLcgAvQs6ta0NEnIm0RJ72/Pv67Jvg07cfBhDbuN74V81lSSVYEFEw==", + "version": "11.1.0", + "integrity": "sha512-xsuczF6mWkEo/E80QHaEokQzKmTy7m80KcLRnkUt9wrcePres2w7dbQi5zOsWtDUg8Dac4gPFyu4EJ/IvKwTOg==", "dev": true, "license": "MIT", "dependencies": { "cli-truncate": "^6.1.1", "log-update": "^8.0.0", - "wrap-ansi": "^10.0.0" + "wrap-ansi": "^10.0.1" }, "engines": { "node": ">=22.13.0" @@ -16678,8 +16179,8 @@ } }, "node_modules/log-update/node_modules/ansi-regex": { - "version": "6.3.0", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "version": "6.4.0", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", "dev": true, "license": "MIT", "engines": { @@ -16720,12 +16221,12 @@ "license": "MIT" }, "node_modules/magic-string": { - "version": "1.0.0", - "integrity": "sha512-CGvjzMN08iv6w1mm4/x3Gh1hLb4VnyRUA15FFpl6CsCIGGoe36k7kY5KNz9QDbSBN5I/fWHM6ZlIkUTa5xdUEA==", + "version": "1.4.1", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.6.0" } }, "node_modules/make-dir": { @@ -16776,6 +16277,18 @@ "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/markdown-it/node_modules/entities": { + "version": "4.5.0", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", @@ -16812,20 +16325,20 @@ } }, "node_modules/memfs": { - "version": "4.78.1", - "integrity": "sha512-SkSlK6DZbzekBoiu0mcHUx9RRPnsBdaKQ36u8F3HvYZm/lRiD34YabQ2Xr76r+zYENzRPFKBsanAZPSl+r3Nqg==", + "version": "4.80.0", + "integrity": "sha512-N70W2isi44XdGn7uPo9NJ4zRukcdkwSFHlnYx42SWOH/+hzqNc8P7+tVoA2up4z7faHvNi1RSNCFjxoYQ71VPQ==", "dev": true, "license": "Apache-2.0", "peer": true, "dependencies": { - "@jsonjoy.com/fs-core": "4.78.1", - "@jsonjoy.com/fs-fsa": "4.78.1", - "@jsonjoy.com/fs-node": "4.78.1", - "@jsonjoy.com/fs-node-builtins": "4.78.1", - "@jsonjoy.com/fs-node-to-fsa": "4.78.1", - "@jsonjoy.com/fs-node-utils": "4.78.1", - "@jsonjoy.com/fs-print": "4.78.1", - "@jsonjoy.com/fs-snapshot": "4.78.1", + "@jsonjoy.com/fs-core": "4.80.0", + "@jsonjoy.com/fs-fsa": "4.80.0", + "@jsonjoy.com/fs-node": "4.80.0", + "@jsonjoy.com/fs-node-builtins": "4.80.0", + "@jsonjoy.com/fs-node-to-fsa": "4.80.0", + "@jsonjoy.com/fs-node-utils": "4.80.0", + "@jsonjoy.com/fs-print": "4.80.0", + "@jsonjoy.com/fs-snapshot": "4.80.0", "@jsonjoy.com/json-pack": "^1.11.0", "@jsonjoy.com/util": "^1.9.0", "glob-to-regex.js": "^1.3.1", @@ -16856,16 +16369,6 @@ "dev": true, "license": "MIT" }, - "node_modules/methods": { - "version": "1.1.2", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/micromatch": { "version": "4.0.8", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", @@ -16898,6 +16401,7 @@ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", "dev": true, "license": "MIT", + "optional": true, "bin": { "mime": "cli.js" }, @@ -16972,13 +16476,6 @@ "webpack": "^5.0.0" } }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true, - "license": "ISC", - "peer": true - }, "node_modules/minimatch": { "version": "10.2.6", "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", @@ -17004,8 +16501,8 @@ } }, "node_modules/minimizer-webpack-plugin": { - "version": "5.10.1", - "integrity": "sha512-+dsZEyTcy1lkq41lxdiOqvb26gXbYGgwY+30w37f9PqUVkuEBejBLDotsHOTjuga9xJyGLW2DqzKDUyJ4W/PMQ==", + "version": "5.12.0", + "integrity": "sha512-PCN1x1OzFeFB2GlP91s46q7Gz8Jf16Z7M45bzpoqrYZlh12QeY6tYVM5h299wbwy1DxnQ69VFmmh8Hb2Ff8mbw==", "dev": true, "license": "MIT", "peer": true, @@ -17076,13 +16573,6 @@ } } }, - "node_modules/minimizer-webpack-plugin/node_modules/commander": { - "version": "2.20.3", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/minimizer-webpack-plugin/node_modules/jest-worker": { "version": "27.5.1", "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", @@ -17114,25 +16604,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/minimizer-webpack-plugin/node_modules/terser": { - "version": "5.51.2", - "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/minipass": { "version": "7.1.3", "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", @@ -17142,6 +16613,11 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mitt": { + "version": "3.0.1", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.1", "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", @@ -17215,7 +16691,6 @@ "node_modules/nanoid": { "version": "3.3.19", "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", - "dev": true, "funding": [ { "type": "github", @@ -17317,30 +16792,29 @@ "license": "MIT" }, "node_modules/ng-packagr": { - "version": "22.1.1", - "integrity": "sha512-jZzpQckw2SFvuYI3aWfYMefSVKKG/04E+vrX2KD6coMx7ciLBotVWuLc0331Fjb6XNmAVcsmgQ5NY/Lf+9Himw==", + "version": "22.2.3", + "integrity": "sha512-beDEoCQTYkwzvA7OcBTw3zfDUNiVddynjvIOb76ImuTcFB83pvHqCdtAXa8aRl0rxyKbNcE7gY2DyGr/OsDUdA==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", - "@rollup/plugin-json": "^6.1.0", - "@rollup/wasm-node": "^4.24.0", "ajv": "^8.17.1", "browserslist": "^4.26.0", "chokidar": "^5.0.0", "commander": "^15.0.0", "dependency-graph": "^1.0.0", - "esbuild": "^0.28.0", + "esbuild": "^0.28.2", "find-cache-directory": "^6.0.0", "injection-js": "^2.4.0", "jsonc-parser": "^3.3.1", "less": "^4.2.0", "ora": "^9.0.0", - "piscina": "^5.0.0", "postcss": "^8.4.47", - "rollup-plugin-dts": "~6.4.1", + "rolldown": "^1.2.0", + "rolldown-plugin-dts": "^0.28.0", "rxjs": "^7.8.1", - "sass": "^1.81.0", + "sass": "^1.103.1", + "sass-embedded": "^1.103.1", "tinyglobby": "^0.2.12" }, "bin": { @@ -17349,11 +16823,8 @@ "engines": { "node": "^22.22.3 || ^24.15.0 || >=26.0.0" }, - "optionalDependencies": { - "rollup": "^4.24.0" - }, "peerDependencies": { - "@angular/compiler-cli": "^22.0.0 || ^22.1.0-next || ^22.2.0-next", + "@angular/compiler-cli": "^22.0.0 || ^22.2.0-next || ^22.3.0-next", "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", "tslib": "^2.3.0", "typescript": ">=6.0 <6.1" @@ -17419,8 +16890,8 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.56", - "integrity": "sha512-x0InOIyzgdk+eyaWaRJFH5snEtiImgBgblZ2CyPrLmqqcuMQkEvcDPHbzqbD8eDsSeJbVOjn+crzyzHaM4D+/A==", + "version": "2.0.57", + "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", "dev": true, "license": "MIT", "engines": { @@ -17464,14 +16935,18 @@ } }, "node_modules/nth-check": { - "version": "2.1.1", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "version": "3.0.1", + "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "boolbase": "^1.0.0" + "boolbase": "^2.0.0" + }, + "engines": { + "node": ">=20.19.0" }, "funding": { + "type": "github", "url": "https://github.com/fb55/nth-check?sponsor=1" } }, @@ -17596,13 +17071,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/obuf": { - "version": "1.1.2", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/obug": { "version": "2.2.1", "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", @@ -17663,18 +17131,18 @@ } }, "node_modules/open": { - "version": "11.0.0", - "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "version": "11.0.4", + "integrity": "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "default-browser": "^5.4.0", + "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", - "powershell-utils": "^0.1.0", - "wsl-utils": "^0.3.0" + "powershell-utils": "^0.2.1", + "wsl-utils": "^1.0.0" }, "engines": { "node": ">=20" @@ -17735,8 +17203,8 @@ } }, "node_modules/ordered-binary": { - "version": "1.6.1", - "integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==", + "version": "1.6.2", + "integrity": "sha512-BY+qgDArmYpvvmij141xbkB4Cw8RFLBQM5Zj4MODFb1SyXBRE/xO4Xj/QWbR7aUEBxbkjuNdsFVCL2TmbYrlNg==", "dev": true, "license": "MIT", "optional": true @@ -17760,40 +17228,39 @@ } }, "node_modules/oxc-parser": { - "version": "0.142.0", - "integrity": "sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==", + "version": "0.150.0", + "integrity": "sha512-zwajKw1GUa57IOdafIS7/yOObGF2NihwIfZfs5yJTRprMSkiwhWOc/k+n8bwnUPbHr2/XsjfBO8KyTPaW3VpWg==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "^0.142.0" + "@oxc-project/types": "^0.150.0" }, "engines": { "node": "^20.19.0 || >=22.12.0" }, "funding": { - "url": "https://github.com/sponsors/Boshen" + "url": "https://github.com/sponsors/oxc-project" }, "optionalDependencies": { - "@oxc-parser/binding-android-arm-eabi": "0.142.0", - "@oxc-parser/binding-android-arm64": "0.142.0", - "@oxc-parser/binding-darwin-arm64": "0.142.0", - "@oxc-parser/binding-darwin-x64": "0.142.0", - "@oxc-parser/binding-freebsd-x64": "0.142.0", - "@oxc-parser/binding-linux-arm-gnueabihf": "0.142.0", - "@oxc-parser/binding-linux-arm-musleabihf": "0.142.0", - "@oxc-parser/binding-linux-arm64-gnu": "0.142.0", - "@oxc-parser/binding-linux-arm64-musl": "0.142.0", - "@oxc-parser/binding-linux-ppc64-gnu": "0.142.0", - "@oxc-parser/binding-linux-riscv64-gnu": "0.142.0", - "@oxc-parser/binding-linux-riscv64-musl": "0.142.0", - "@oxc-parser/binding-linux-s390x-gnu": "0.142.0", - "@oxc-parser/binding-linux-x64-gnu": "0.142.0", - "@oxc-parser/binding-linux-x64-musl": "0.142.0", - "@oxc-parser/binding-openharmony-arm64": "0.142.0", - "@oxc-parser/binding-wasm32-wasi": "0.142.0", - "@oxc-parser/binding-win32-arm64-msvc": "0.142.0", - "@oxc-parser/binding-win32-ia32-msvc": "0.142.0", - "@oxc-parser/binding-win32-x64-msvc": "0.142.0" + "@oxc-parser/binding-android-arm-eabi": "0.150.0", + "@oxc-parser/binding-android-arm64": "0.150.0", + "@oxc-parser/binding-darwin-arm64": "0.150.0", + "@oxc-parser/binding-darwin-x64": "0.150.0", + "@oxc-parser/binding-freebsd-x64": "0.150.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.150.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.150.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.150.0", + "@oxc-parser/binding-linux-arm64-musl": "0.150.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.150.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.150.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.150.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.150.0", + "@oxc-parser/binding-linux-x64-gnu": "0.150.0", + "@oxc-parser/binding-linux-x64-musl": "0.150.0", + "@oxc-parser/binding-openharmony-arm64": "0.150.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.150.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.150.0", + "@oxc-parser/binding-win32-x64-msvc": "0.150.0" } }, "node_modules/p-limit": { @@ -17827,18 +17294,16 @@ } }, "node_modules/p-retry": { - "version": "6.2.1", - "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", + "version": "8.0.1", + "integrity": "sha512-NAigyu8hfvJe7MsS18mnc4is0MZtau3QMdBklaJKK23oBQb6occO3UPM3vHmTckW8KhfyjMZaUOqdTFEYliHgg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@types/retry": "0.12.2", - "is-network-error": "^1.0.0", - "retry": "^0.13.1" + "is-network-error": "^1.3.0" }, "engines": { - "node": ">=16.17" + "node": ">=22" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -17953,18 +17418,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parse5-html-rewriting-stream/node_modules/entities": { - "version": "8.1.0", - "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/parse5-html-rewriting-stream/node_modules/parse5": { "version": "8.0.1", "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", @@ -17989,18 +17442,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parse5-sax-parser/node_modules/entities": { - "version": "8.1.0", - "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/parse5-sax-parser/node_modules/parse5": { "version": "8.0.1", "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", @@ -18096,12 +17537,11 @@ "node_modules/picocolors": { "version": "1.1.1", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -18121,8 +17561,8 @@ } }, "node_modules/piscina": { - "version": "5.2.0", - "integrity": "sha512-DszUCKeVN/5G5QKo6jAVHL8fmKnkJvQ0ACiVgY7YGCq3TUB2oznAOayvZPIAdEThvhczkXR+qm3IHsNXpFCYfA==", + "version": "5.3.2", + "integrity": "sha512-vv7l/mM7B9WgrhDaudIqv1x6v4LOBRYYmpWGdWushhpbCmVHp11sUB2v4hj1CAeTGXVxuZFz6xOU1RalAiPKfQ==", "dev": true, "license": "MIT", "engines": { @@ -18206,13 +17646,16 @@ } }, "node_modules/pkijs": { - "version": "3.4.0", - "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "version": "3.4.1", + "integrity": "sha512-Oo/NZcSWccq8KyoG7gLE9fnltgHns+pNCjCAp/WmjsUySi+sX7y4z4Xqu4fVb42CDHzRPl33fjzT15V1wvcyhA==", "dev": true, "license": "BSD-3-Clause", "peer": true, + "workspaces": [ + "website" + ], "dependencies": { - "@noble/hashes": "1.4.0", + "@noble/hashes": "1.8.0", "asn1js": "^3.0.6", "bytestreamjs": "^2.0.1", "pvtsutils": "^1.3.6", @@ -18223,19 +17666,6 @@ "node": ">=16.0.0" } }, - "node_modules/pkijs/node_modules/@noble/hashes": { - "version": "1.4.0", - "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/playwright": { "version": "1.63.0", "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", @@ -18273,9 +17703,8 @@ } }, "node_modules/postcss": { - "version": "8.5.25", - "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", - "dev": true, + "version": "8.5.28", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "funding": [ { "type": "opencollective", @@ -18292,7 +17721,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -18449,8 +17878,8 @@ "peer": true }, "node_modules/powershell-utils": { - "version": "0.1.0", - "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "version": "0.2.1", + "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==", "dev": true, "license": "MIT", "peer": true, @@ -18471,8 +17900,8 @@ } }, "node_modules/prettier": { - "version": "3.9.8", - "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==", + "version": "3.9.9", + "integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==", "dev": true, "license": "MIT", "bin": { @@ -18592,16 +18021,9 @@ "integrity": "sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==", "dev": true, "license": "ISC", - "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, - "license": "MIT", - "peer": true + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } }, "node_modules/proxy-addr": { "version": "2.0.8", @@ -18762,21 +18184,6 @@ "node": ">= 0.10" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/readdirp": { "version": "5.1.1", "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", @@ -18826,8 +18233,8 @@ "peer": true }, "node_modules/regenerate-unicode-properties": { - "version": "10.2.2", - "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "version": "10.3.0", + "integrity": "sha512-9ns8odR8e9q3otKeHj3DE80jZYKg6zVeZKE+zQmRKXFoVUcuiTGeO6iusDhPRoIQQPVPRvH+SfBhrTud4eczuw==", "dev": true, "license": "MIT", "peer": true, @@ -18878,14 +18285,14 @@ } }, "node_modules/regexpu-core": { - "version": "6.4.0", - "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "version": "6.5.2", + "integrity": "sha512-zkmVH92DlzjSFc4xKGZp/2BXIFrDIdBQBC8gUXKCsMgKYb1bkCkxfSuoW1werDjaTEMTbULkHtKFHe1L+Abnaw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.2", + "regenerate-unicode-properties": "^10.3.0", "regjsgen": "^0.8.0", "regjsparser": "^0.13.0", "unicode-match-property-ecmascript": "^2.0.0", @@ -18933,13 +18340,6 @@ "node": ">=0.10.0" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/resolve": { "version": "2.0.0-next.7", "integrity": "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ==", @@ -19075,16 +18475,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/retry": { - "version": "0.13.1", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 4" - } - }, "node_modules/reusify": { "version": "1.1.0", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", @@ -19161,1265 +18551,1033 @@ } }, "node_modules/rolldown": { - "version": "1.2.0", - "integrity": "sha512-u7tgm5l4Yw1iTqUL4EcYOAt7fFvCgQMLeidrnD4GALlC6aOznCjezYajgxeyKw27u0Q5N7fwgCzjVyPIWzwuBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.140.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.2.0", - "@rolldown/binding-darwin-arm64": "1.2.0", - "@rolldown/binding-darwin-x64": "1.2.0", - "@rolldown/binding-freebsd-x64": "1.2.0", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.0", - "@rolldown/binding-linux-arm64-gnu": "1.2.0", - "@rolldown/binding-linux-arm64-musl": "1.2.0", - "@rolldown/binding-linux-ppc64-gnu": "1.2.0", - "@rolldown/binding-linux-s390x-gnu": "1.2.0", - "@rolldown/binding-linux-x64-gnu": "1.2.0", - "@rolldown/binding-linux-x64-musl": "1.2.0", - "@rolldown/binding-openharmony-arm64": "1.2.0", - "@rolldown/binding-wasm32-wasi": "1.2.0", - "@rolldown/binding-win32-arm64-msvc": "1.2.0", - "@rolldown/binding-win32-x64-msvc": "1.2.0" - } - }, - "node_modules/rolldown/node_modules/@oxc-project/types": { - "version": "0.140.0", - "integrity": "sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/rollup": { - "version": "4.63.4", - "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "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" - } - }, - "node_modules/rollup-plugin-dts": { - "version": "6.4.1", - "integrity": "sha512-l//F3Zf7ID5GoOfLfD8kroBjQKEKpy1qfhtAdnpibFZMffPaylrg1CoDC2vGkPeTeyxUe4bVFCln2EFuL7IGGg==", - "dev": true, - "license": "LGPL-3.0-only", - "dependencies": { - "@jridgewell/remapping": "^2.3.5", - "@jridgewell/sourcemap-codec": "^1.5.5", - "convert-source-map": "^2.0.0", - "magic-string": "^0.30.21" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/Swatinem" - }, - "optionalDependencies": { - "@babel/code-frame": "^7.29.0" - }, - "peerDependencies": { - "rollup": "^3.29.4 || ^4", - "typescript": "^4.5 || ^5.0 || ^6.0" - } - }, - "node_modules/rollup-plugin-dts/node_modules/@babel/code-frame": { - "version": "7.29.7", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/rollup-plugin-dts/node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/rollup-plugin-dts/node_modules/convert-source-map": { - "version": "2.0.0", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/rollup-plugin-dts/node_modules/js-tokens": { - "version": "4.0.0", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/rollup-plugin-dts/node_modules/magic-string": { - "version": "0.30.21", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/router": { - "version": "2.2.0", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/rrweb-cssom": { - "version": "0.8.0", - "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/run-parallel": { - "version": "1.2.0", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "node_modules/safe-array-concat": { - "version": "1.1.4", - "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "get-intrinsic": "^1.3.0", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "peer": true - }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/sass": { - "version": "1.101.0", - "integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==", + "version": "1.2.8", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", "dev": true, "license": "MIT", "dependencies": { - "chokidar": "^5.0.0", - "immutable": "^5.1.5", - "source-map-js": ">=0.6.2 <2.0.0" + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { - "sass": "sass.js" + "rolldown": "bin/cli.mjs" }, "engines": { - "node": ">=20.19.0" + "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@parcel/watcher": "^2.4.1" + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" } }, - "node_modules/sass-loader": { - "version": "17.0.0", - "integrity": "sha512-0Ybm8ohBQ9LcrycVrFQp/KQBNX5a3Wda9/smS0mE/xLffzEnwvV8nykOzrbiSWNzTE3IB/jiXx8O4QmDPG2+Gw==", + "node_modules/rolldown-plugin-dts": { + "version": "0.28.6", + "integrity": "sha512-qKrFtBfRfR2hP233m7Ic9zf3wv6MSYdZghWKMdEO6dRYZGlNfINJAa1P5ZVvyHh3mrcd7IJQvIY8L9vnm+v5rQ==", "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "dts-resolver": "^3.0.0", + "get-tsconfig": "5.0.0-beta.6", + "obug": "^3.0.0", + "yuku-ast": "^0.10.1", + "yuku-codegen": "^0.10.1", + "yuku-parser": "^0.10.1" + }, "engines": { - "node": ">= 22.11.0" + "node": "^22.18.0 || ^24.11.0 || >=26.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://github.com/sponsors/sxzz" }, "peerDependencies": { - "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", - "sass": "^1.3.0", - "sass-embedded": "*", - "webpack": "^5.0.0" + "@typescript/native-preview": "*", + "@volar/typescript": "~2.4.0", + "@vue/language-core": "~3.2.0 || ~3.3.0", + "rolldown": "^1.2.0", + "typescript": "^5.0.0 || ^6.0.0 || ~7.0.0", + "vue-tsc": "~3.2.0 || ~3.3.0" }, "peerDependenciesMeta": { - "@rspack/core": { + "@typescript/native-preview": { "optional": true }, - "sass": { + "@volar/typescript": { "optional": true }, - "sass-embedded": { + "@vue/language-core": { "optional": true }, - "webpack": { + "typescript": { + "optional": true + }, + "vue-tsc": { "optional": true } } }, - "node_modules/sax": { - "version": "1.6.1", - "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", - "dev": true, - "license": "BlueOak-1.0.0", - "optional": true, - "engines": { - "node": ">=11.0.0" - } - }, - "node_modules/saxes": { - "version": "6.0.0", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/schema-utils": { - "version": "4.5.0", - "integrity": "sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==", + "node_modules/rolldown-plugin-dts/node_modules/get-tsconfig": { + "version": "5.0.0-beta.6", + "integrity": "sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@types/json-schema": "^7.0.15", - "ajv": "^8.20.0", - "ajv-formats": "^3.0.1", - "ajv-keywords": "^5.1.0" + "resolve-pkg-maps": "^1.0.0" }, "engines": { - "node": ">= 10.13.0" + "node": ">=20.20.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/select-hose": { - "version": "2.0.0", - "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/selfsigned": { - "version": "5.5.0", - "integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", + "node_modules/rolldown-plugin-dts/node_modules/obug": { + "version": "3.0.0", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], "license": "MIT", - "peer": true, - "dependencies": { - "@peculiar/x509": "^1.14.2", - "pkijs": "^3.3.3" - }, "engines": { - "node": ">=18" + "node": ">=12.20.0" } }, - "node_modules/semver": { - "version": "7.8.5", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "node_modules/rolldown/node_modules/@oxc-project/types": { + "version": "0.149.0", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" } }, - "node_modules/send": { - "version": "1.2.1", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "node_modules/router": { + "version": "2.2.0", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" }, "engines": { "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serialize-javascript": { - "version": "7.1.1", - "integrity": "sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true, - "engines": { - "node": ">=20.0.0" } }, - "node_modules/serve-index": { - "version": "1.9.2", - "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", - "dev": true, + "node_modules/rrdom": { + "version": "0.1.7", + "integrity": "sha512-ZLd8f14z9pUy2Hk9y636cNv5Y2BMnNEY99wxzW9tD2BLDfe1xFxtLjB4q/xCBYo6HRe0wofzKzjm4JojmpBfFw==", "license": "MIT", - "peer": true, "dependencies": { - "accepts": "~1.3.8", - "batch": "0.6.1", - "debug": "2.6.9", - "escape-html": "~1.0.3", - "http-errors": "~1.8.0", - "mime-types": "~2.1.35", - "parseurl": "~1.3.3" - }, - "engines": { - "node": ">= 0.8.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "rrweb-snapshot": "^2.0.0-alpha.4" } }, - "node_modules/serve-index/node_modules/accepts": { - "version": "1.3.8", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, + "node_modules/rrweb": { + "version": "2.0.0-alpha.4", + "integrity": "sha512-wEHUILbxDPcNwkM3m4qgPgXAiBJyqCbbOHyVoNEVBJzHszWEFYyTbrZqUdeb1EfmTRC2PsumCIkVcomJ/xcOzA==", "license": "MIT", - "peer": true, "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" + "@rrweb/types": "^2.0.0-alpha.4", + "@types/css-font-loading-module": "0.0.7", + "@xstate/fsm": "^1.4.0", + "base64-arraybuffer": "^1.0.1", + "fflate": "^0.4.4", + "mitt": "^3.0.0", + "rrdom": "^0.1.7", + "rrweb-snapshot": "^2.0.0-alpha.4" } }, - "node_modules/serve-index/node_modules/debug": { - "version": "2.6.9", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", "dev": true, + "license": "MIT" + }, + "node_modules/rrweb-snapshot": { + "version": "2.1.6", + "integrity": "sha512-X03+x3AdWTbpD4Swb54CCJoEauYTv/O2ah0d3ZVv5rgr6bv0Ty5mxNjEyEEfNC1dQ8VdyjKKJgDG3A00FV0lNg==", "license": "MIT", - "peer": true, "dependencies": { - "ms": "2.0.0" + "postcss": "^8.4.38" } }, - "node_modules/serve-index/node_modules/depd": { - "version": "1.1.2", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "node_modules/run-applescript": { + "version": "7.1.0", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", "dev": true, "license": "MIT", "peer": true, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/serve-index/node_modules/http-errors": { - "version": "1.8.1", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "node_modules/run-parallel": { + "version": "1.2.0", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT", - "peer": true, "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.6" + "queue-microtask": "^1.2.2" } }, - "node_modules/serve-index/node_modules/mime-db": { - "version": "1.52.0", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" + "node_modules/rxjs": { + "version": "7.8.2", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" } }, - "node_modules/serve-index/node_modules/mime-types": { - "version": "2.1.35", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/safe-array-concat": { + "version": "1.1.4", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "mime-db": "1.52.0" + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" }, "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/ms": { - "version": "2.0.0", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/serve-index/node_modules/negotiator": { - "version": "0.6.3", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/serve-index/node_modules/statuses": { - "version": "1.5.0", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "node_modules/safe-buffer": { + "version": "5.2.1", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } + "peer": true }, - "node_modules/serve-static": { - "version": "2.2.1", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "node_modules/safe-push-apply": { + "version": "1.0.0", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", "dev": true, "license": "MIT", "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" + "es-errors": "^1.3.0", + "isarray": "^2.0.5" }, "engines": { - "node": ">= 18" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/set-function-length": { - "version": "1.2.2", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "node_modules/safe-regex-test": { + "version": "1.1.0", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "dev": true, "license": "MIT", "dependencies": { - "define-data-property": "^1.1.4", + "call-bound": "^1.0.2", "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" + "is-regex": "^1.2.1" }, "engines": { "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/set-function-name": { - "version": "2.0.2", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "node_modules/safer-buffer": { + "version": "2.1.2", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.104.1", + "integrity": "sha512-yDA+1aIG3EHgN4V/BvuhCvu61FF6hEd4e+9DxikUm9U0CAGuvdIZ/UYy7qbOxjhbbWQraoyLlMuzdRGOSV5Bmw==", "dev": true, "license": "MIT", "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" }, "engines": { - "node": ">= 0.4" + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" } }, - "node_modules/set-proto": { - "version": "1.0.0", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "node_modules/sass-embedded": { + "version": "1.104.1", + "integrity": "sha512-7LxbtPJ1MWKVbhzOnGx1sHTkHTcHVJ9hbgVsU29p/8gY1VeXt19fcJFr5xsx2f6zfrrR3FeOcYMt0uYDLAxpgw==", "dev": true, "license": "MIT", "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" + "@bufbuild/protobuf": "^2.5.0", + "colorjs.io": "^0.7.0", + "immutable": "^5.1.5", + "rxjs": "^7.4.0", + "supports-color": "^8.1.1", + "sync-child-process": "^1.0.2", + "varint": "^6.0.0" + }, + "bin": { + "sass": "dist/bin/sass.js" }, "engines": { - "node": ">= 0.4" + "node": ">=20.19.0" + }, + "optionalDependencies": { + "sass-embedded-all-unknown": "1.104.1", + "sass-embedded-android-arm": "1.104.1", + "sass-embedded-android-arm64": "1.104.1", + "sass-embedded-android-riscv64": "1.104.1", + "sass-embedded-android-x64": "1.104.1", + "sass-embedded-darwin-arm64": "1.104.1", + "sass-embedded-darwin-x64": "1.104.1", + "sass-embedded-linux-arm": "1.104.1", + "sass-embedded-linux-arm64": "1.104.1", + "sass-embedded-linux-musl-arm": "1.104.1", + "sass-embedded-linux-musl-arm64": "1.104.1", + "sass-embedded-linux-musl-riscv64": "1.104.1", + "sass-embedded-linux-musl-x64": "1.104.1", + "sass-embedded-linux-riscv64": "1.104.1", + "sass-embedded-linux-x64": "1.104.1", + "sass-embedded-unknown-all": "1.104.1", + "sass-embedded-win32-arm64": "1.104.1", + "sass-embedded-win32-x64": "1.104.1" + } + }, + "node_modules/sass-embedded-all-unknown": { + "version": "1.104.1", + "integrity": "sha512-toyMCyUKkT6DsYlHuKZGjd8T6WltaW4TGX07F7HnF4tNJM5m/8VaVsd74zrq3QSPqGE9JjxIDK4NrNc/QY9pkA==", + "cpu": [ + "!arm", + "!arm64", + "!riscv64", + "!x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "sass": "1.104.1" } }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "node_modules/sass-embedded-android-arm": { + "version": "1.104.1", + "integrity": "sha512-g/XPAJYAzSSQZFP4xCdGD0HLKB4mf24cJ/+5Nls8rMool3jNkBv9+AFO4EV0b+RxN8rLcEsMccH1CR64yIFYcQ==", + "cpu": [ + "arm" + ], "dev": true, - "license": "ISC" + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=14.0.0" + } }, - "node_modules/shallow-clone": { - "version": "3.0.1", - "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "node_modules/sass-embedded-android-arm64": { + "version": "1.104.1", + "integrity": "sha512-yW3BVW4YFpN2vu2cVQ+Pz5JrRTvoIz36aCdo+J/Mo1A9gEN8eVPV/AL7aeZ803YdKgIitd3qw3pY4wpmT6TXNg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "kind-of": "^6.0.2" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=8" + "node": ">=14.0.0" } }, - "node_modules/shebang-command": { - "version": "2.0.0", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "node_modules/sass-embedded-android-riscv64": { + "version": "1.104.1", + "integrity": "sha512-ZYY8oMKQS7Q/6FBoC8w3T16O3MucDe3xv8iKym41Nht5uvLCGGpm4fC4OF5nlv7wQkJv8oactYddGG3Wd0Rqkg==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=8" + "node": ">=14.0.0" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "node_modules/sass-embedded-android-x64": { + "version": "1.104.1", + "integrity": "sha512-5WZKC4lSfjMIjzZdQUlYQUAV+UiatBhD8w28U7cYLLj4/dMgEPOBd4g3VMv0BwpgH2JOaycMS42dXq29W2bcDg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=8" + "node": ">=14.0.0" } }, - "node_modules/shell-quote": { - "version": "1.10.0", - "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", + "node_modules/sass-embedded-darwin-arm64": { + "version": "1.104.1", + "integrity": "sha512-oatuHzn0NsEg0d9Dcze6CuWUPvuETtMxRK4cv0eRAg7p6R0vHnv5GZRyWG6gEy3nc/ha/L+GtTasWVU82f4jrQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "peer": true, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=14.0.0" } }, - "node_modules/side-channel": { - "version": "1.1.1", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "node_modules/sass-embedded-darwin-x64": { + "version": "1.104.1", + "integrity": "sha512-gql/5sHGzUTVwuHfM3QLCDeb6NYwen+LLj22gz81C2NKHF/4nUtDyh4N+fURWdT7J0/EhtMOd0+FLML3sGNP7g==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=14.0.0" } }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "node_modules/sass-embedded-linux-arm": { + "version": "1.104.1", + "integrity": "sha512-WfmshPJOp9vUKxgp8wZ2ncaKBbyrlW9J51M2aUQHWECjMNxTULnGOHWSmD7FViXv4zwn1Co5FIFQUipW7RCxtA==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=14.0.0" } }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "node_modules/sass-embedded-linux-arm64": { + "version": "1.104.1", + "integrity": "sha512-sVWUKXNTu8sP3X95Df0xo/t49Ce5IseTIMqXHxhFBKVmx7gf7kBWNLK0wH5ym+4khc+B5nNJPgq4IxpelG8tMQ==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=14.0.0" } }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "node_modules/sass-embedded-linux-musl-arm": { + "version": "1.104.1", + "integrity": "sha512-k0cQ/CxBzFVYiQEObg6CpF6CUglkdEhi/DZgZXQbwC27kbGGY+fatmIs/cFPJL2z1ulACwoAr86RQUFzYvw5eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/sass-embedded-linux-musl-arm64": { + "version": "1.104.1", + "integrity": "sha512-H7b+LzZ/w8pwNExInM8HDHraqvxCMdUc5ZRGU/aSJJhZ9HjeRKBjF3GxaisVHnx7o18j1l4VQ5cLBL9tTmNzZw==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=14.0.0" } }, - "node_modules/signal-exit": { - "version": "4.1.0", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "node_modules/sass-embedded-linux-musl-riscv64": { + "version": "1.104.1", + "integrity": "sha512-9DYdn4wfrkidjxJVtEd2iCx8HKdZe+uF82GDmrYEZhgu4NjVE6a4fQyW7OLwzn3erZMHKBaObBOH1d37DnXI1A==", + "cpu": [ + "riscv64" + ], "dev": true, - "license": "ISC", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=14.0.0" } }, - "node_modules/slash": { - "version": "3.0.0", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "node_modules/sass-embedded-linux-musl-x64": { + "version": "1.104.1", + "integrity": "sha512-5xz+nAr01b6yaRdrGVgE7lNvm1lGFjwNdUvnz+teidvHCbD5oTcbhrRVrnDoLHHvq5n3Jve9TzWaaHhezaEfHA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">=14.0.0" } }, - "node_modules/slice-ansi": { - "version": "9.0.1", - "integrity": "sha512-aBY19bn/XA+hKOKX0qL7Z0UfdBsbQvc9hn993U8ALUjzxCvDcuZqLoRXjGJrUARWAlwMnsRVD9sw2AFNOYvalA==", + "node_modules/sass-embedded-linux-riscv64": { + "version": "1.104.1", + "integrity": "sha512-oWryPJWTXK5adtlz/FD7wd2kS4I0CEgp6mWLUVb4oMFURMLz9bA9fO87D7lc4SV++lds7T51PU1kykCbOvNHMg==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=22" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "node": ">=14.0.0" } }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/sass-embedded-linux-x64": { + "version": "1.104.1", + "integrity": "sha512-j/sX/yKTx/wJKepC5/ISlR6kPeSuHKThf76D2za5i2VnbHg0MN19jQq3WMorowR3kRghqt6azs3awogG+e7IRA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "node": ">=14.0.0" } }, - "node_modules/sockjs": { - "version": "0.3.24", - "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "node_modules/sass-embedded-unknown-all": { + "version": "1.104.1", + "integrity": "sha512-RaMsYZB5mRioL4qhoI7T5mmNb2OunakbPTJW2cuu3MeIULnGoxo7Wd5QKFxeQvF7sSQL57RuaQ+q9pH+/pQ9Fg==", "dev": true, "license": "MIT", - "peer": true, + "optional": true, + "os": [ + "!android", + "!darwin", + "!linux", + "!win32" + ], "dependencies": { - "faye-websocket": "^0.11.3", - "uuid": "^8.3.2", - "websocket-driver": "^0.7.4" + "sass": "1.104.1" } }, - "node_modules/source-map": { - "version": "0.7.6", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "node_modules/sass-embedded-win32-arm64": { + "version": "1.104.1", + "integrity": "sha512-fS6DL7lcnTPclqGLF4xRt8egZlFY9cU23d57ufbWqaUbCFu9JTu0+8qB4jMxfx1la6byi3GoSTfbnNOLfWv8sw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 12" + "node": ">=14.0.0" } }, - "node_modules/source-map-js": { - "version": "1.2.1", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "node_modules/sass-embedded-win32-x64": { + "version": "1.104.1", + "integrity": "sha512-/c4N2A93Ckms6DuRFVftKYISuI1tyC8gmlVJ8WaWFzgCBN/Swq2rARJIBUNeuxlmxjmYXfd3+2f/u887K63wbA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">=0.10.0" + "node": ">=14.0.0" } }, - "node_modules/source-map-loader": { - "version": "5.0.0", - "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", + "node_modules/sass-embedded/node_modules/supports-color": { + "version": "8.1.1", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "iconv-lite": "^0.6.3", - "source-map-js": "^1.0.2" + "has-flag": "^4.0.0" }, "engines": { - "node": ">= 18.12.0" + "node": ">=10" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.72.1" + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/source-map-loader/node_modules/iconv-lite": { - "version": "0.6.3", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "node_modules/sass-loader": { + "version": "17.0.1", + "integrity": "sha512-pgJMwCuLjVTSIWsv/2luVRXKlCeaViUGcgSe8dx95zMG4hUwqIMFmjbhq29ypFLflJU0GyiJZeLM9iI1n3KYAA==", "dev": true, "license": "MIT", "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, "engines": { - "node": ">=0.10.0" + "node": ">= 22.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "webpack": { + "optional": true + } } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "node_modules/sax": { + "version": "1.6.1", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=11.0.0" } }, - "node_modules/source-map-support/node_modules/source-map": { - "version": "0.6.1", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/saxes": { + "version": "6.0.0", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", "dev": true, - "license": "BSD-3-Clause", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">=v12.22.7" } }, - "node_modules/spdy": { - "version": "4.0.2", - "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "node_modules/schema-utils": { + "version": "4.5.0", + "integrity": "sha512-zJlMCZ0cAR5p/Y4oVpRoqioDMJcGxaXRrQ/4rP4WyR84vc5z/DolXdbvXeDpTwbtocDFr2rhPqHPErDCUtz2kA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "debug": "^4.1.0", - "handle-thing": "^2.0.0", - "http-deceiver": "^1.2.7", - "select-hose": "^2.0.0", - "spdy-transport": "^3.0.0" + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" }, "engines": { - "node": ">=6.0.0" + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/spdy-transport": { - "version": "3.0.0", - "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "node_modules/selfsigned": { + "version": "5.5.0", + "integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "debug": "^4.1.0", - "detect-node": "^2.0.4", - "hpack.js": "^2.1.6", - "obuf": "^1.1.2", - "readable-stream": "^3.0.6", - "wbuf": "^1.7.3" + "@peculiar/x509": "^1.14.2", + "pkijs": "^3.3.3" + }, + "engines": { + "node": ">=18" } }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "node_modules/semver": { + "version": "7.8.5", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { "node": ">=10" } }, - "node_modules/stack-utils/node_modules/escape-string-regexp": { - "version": "2.0.0", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "node_modules/send": { + "version": "1.2.1", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "dev": true, "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, "engines": { - "node": ">=8" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/statuses": { - "version": "2.0.2", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "node_modules/serialize-javascript": { + "version": "7.1.2", + "integrity": "sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "peer": true, "engines": { - "node": ">= 0.8" + "node": ">=20.0.0" } }, - "node_modules/stdin-discarder": { - "version": "0.3.2", - "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", + "node_modules/serve-index": { + "version": "1.9.2", + "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "~1.3.8", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.8.0", + "mime-types": "~2.1.35", + "parseurl": "~1.3.3" + }, "engines": { - "node": ">=18" + "node": ">= 0.8.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "node_modules/serve-index/node_modules/accepts": { + "version": "1.3.8", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" + "mime-types": "~2.1.34", + "negotiator": "0.6.3" }, "engines": { - "node": ">= 0.4" - } - }, - "node_modules/stream-parser": { - "version": "0.3.1", - "integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "debug": "2" + "node": ">= 0.6" } }, - "node_modules/stream-parser/node_modules/debug": { + "node_modules/serve-index/node_modules/debug": { "version": "2.6.9", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, "license": "MIT", - "optional": true, + "peer": true, "dependencies": { "ms": "2.0.0" } }, - "node_modules/stream-parser/node_modules/ms": { - "version": "2.0.0", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", "dev": true, "license": "MIT", "peer": true, - "dependencies": { - "safe-buffer": "~5.2.0" + "engines": { + "node": ">= 0.6" } }, - "node_modules/string-length": { - "version": "4.0.2", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.8.1", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" }, "engines": { - "node": ">=10" + "node": ">= 0.6" } }, - "node_modules/string-width": { - "version": "8.2.2", - "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "node_modules/serve-index/node_modules/mime-db": { + "version": "1.52.0", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "dev": true, "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, + "peer": true, "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.6" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/serve-index/node_modules/mime-types": { + "version": "2.1.35", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "mime-db": "1.52.0" }, "engines": { - "node": ">=8" + "node": ">= 0.6" } }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, - "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "node_modules/serve-index/node_modules/negotiator": { + "version": "0.6.3", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=8" + "node": ">= 0.6" } }, - "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.3.0", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">= 0.6" } }, - "node_modules/string-width/node_modules/strip-ansi": { - "version": "7.2.0", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "node_modules/serve-static": { + "version": "2.2.1", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.2.2" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" }, "engines": { - "node": ">=12" + "node": ">= 18" }, "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/string.prototype.trim": { - "version": "1.2.11", - "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", + "node_modules/set-function-length": { + "version": "1.2.2", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.2", - "es-object-atoms": "^1.1.2", - "has-property-descriptors": "^1.0.2", - "safe-regex-test": "^1.1.0" + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/string.prototype.trimend": { - "version": "1.0.10", - "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", + "node_modules/set-function-name": { + "version": "2.0.2", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.2" + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "node_modules/set-proto": { + "version": "1.0.0", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" } }, - "node_modules/strip-bom": { - "version": "4.0.0", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "node_modules/setprototypeof": { + "version": "1.2.0", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "kind-of": "^6.0.2" + }, "engines": { - "node": ">=6" + "node": ">=8" } }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "node_modules/shebang-command": { + "version": "2.0.0", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, "engines": { "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/supports-color": { - "version": "7.2.0", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "node_modules/shebang-regex": { + "version": "3.0.0", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { "node": ">=8" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "node_modules/shell-quote": { + "version": "1.11.0", + "integrity": "sha512-JdxDPD0DBTyu08pq0kPC0xSNet/qsU07qT6IsX1AS8oO2ICNRY4ldNa8OAI6PuwAH8tG3lxEhbqmyp4Dw4036g==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4" }, @@ -20427,444 +19585,390 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" + "node_modules/shimmer": { + "version": "1.2.1", + "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==", + "license": "BSD-2-Clause" }, - "node_modules/synckit": { - "version": "0.11.13", - "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", + "node_modules/side-channel": { + "version": "1.1.1", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { - "@pkgr/core": "^0.3.6" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": ">= 0.4" }, "funding": { - "url": "https://opencollective.com/synckit" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/tapable": { - "version": "2.3.3", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "node_modules/side-channel-list": { + "version": "1.0.1", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/terser": { - "version": "5.49.0", - "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" }, "engines": { - "node": ">=10" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "node_modules/side-channel-map": { + "version": "1.0.1", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dev": true, "license": "MIT", - "peer": true - }, - "node_modules/test-exclude": { - "version": "7.0.2", - "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", - "dev": true, - "license": "ISC", "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^10.2.2" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" }, "engines": { - "node": ">=18" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/test-exclude/node_modules/balanced-match": { + "node_modules/side-channel-weakmap": { "version": "1.0.2", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "2.1.7", - "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/test-exclude/node_modules/glob": { - "version": "10.5.0", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, - "bin": { - "glob": "dist/esm/bin.mjs" + "engines": { + "node": ">= 0.4" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "node_modules/signal-exit": { + "version": "4.1.0", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=14" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/test-exclude/node_modules/lru-cache": { - "version": "10.4.3", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "node_modules/slash": { + "version": "3.0.0", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", "dev": true, - "license": "ISC" + "license": "MIT", + "engines": { + "node": ">=8" + } }, - "node_modules/test-exclude/node_modules/path-scurry": { - "version": "1.11.1", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "node_modules/slice-ansi": { + "version": "9.0.1", + "integrity": "sha512-aBY19bn/XA+hKOKX0qL7Z0UfdBsbQvc9hn993U8ALUjzxCvDcuZqLoRXjGJrUARWAlwMnsRVD9sw2AFNOYvalA==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": ">=22" }, "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/text-table": { - "version": "0.2.0", - "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true, - "license": "MIT" - }, - "node_modules/thingies": { - "version": "2.6.1", - "integrity": "sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==", + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=10.18" + "node": ">=12" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "^2" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/thunky": { - "version": "1.1.0", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "node_modules/source-map": { + "version": "0.8.0", + "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, + "node_modules/source-map-js": { + "version": "1.2.1", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" + "node": ">=0.10.0" } }, - "node_modules/tldts": { - "version": "7.4.13", - "integrity": "sha512-iHtaIWWIbMDkCeJdTBzZFGgbluE5J+oHlb2g7+oAz1S1gpuVpabRZdQyd471Vl8UUkcz2vXSL8xZH2kyCe8tfA==", + "node_modules/source-map-loader": { + "version": "5.0.0", + "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "tldts-core": "^7.4.13" + "iconv-lite": "^0.6.3", + "source-map-js": "^1.0.2" }, - "bin": { - "tldts": "bin/cli.js" + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.72.1" } }, - "node_modules/tldts-core": { - "version": "7.4.13", - "integrity": "sha512-mbYsrih5FRtGxs3Usvl/PqwJsNpp+jsmrdFviiK02teHDG0/HebBG/pqCylje3kzgXYzuLoHJF/0mz9W53t8Xg==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/source-map-loader/node_modules/iconv-lite": { + "version": "0.6.3", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "is-number": "^7.0.0" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": ">=8.0" + "node": ">=0.10.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "node_modules/source-map-support": { + "version": "0.5.21", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", - "engines": { - "node": ">=0.6" + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" } }, - "node_modules/tough-cookie": { - "version": "6.0.2", - "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "tldts": "^7.0.5" - }, "engines": { - "node": ">=16" + "node": ">=0.10.0" } }, - "node_modules/tr46": { - "version": "6.0.0", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "node_modules/sprintf-js": { + "version": "1.0.3", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "punycode": "^2.3.1" + "escape-string-regexp": "^2.0.0" }, "engines": { - "node": ">=20" + "node": ">=10" } }, - "node_modules/tree-dump": { - "version": "1.1.0", - "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", "dev": true, - "license": "Apache-2.0", - "peer": true, + "license": "MIT", "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" + "node": ">=8" } }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "node_modules/statuses": { + "version": "2.0.2", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "dev": true, "license": "MIT", "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" + "node": ">= 0.8" } }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "node_modules/stdin-discarder": { + "version": "0.3.2", + "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", "dev": true, "license": "MIT", - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tsconfig-paths/node_modules/json5": { - "version": "1.0.2", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", "dev": true, "license": "MIT", "dependencies": { - "minimist": "^1.2.0" + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" }, - "bin": { - "json5": "lib/cli.js" + "engines": { + "node": ">= 0.4" } }, - "node_modules/tsconfig-paths/node_modules/strip-bom": { - "version": "3.0.0", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "node_modules/stream-parser": { + "version": "0.3.1", + "integrity": "sha512-bJ/HgKq41nlKvlhccD5kaCr/P+Hu0wPNKPJOH7en+YrJu/9EgqUF+88w5Jb6KNcjOFMhfX4B2asfeAtIGuHObQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">=4" + "optional": true, + "dependencies": { + "debug": "2" } }, - "node_modules/tslib": { - "version": "2.8.1", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tsyringe": { - "version": "4.10.0", - "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "node_modules/stream-parser/node_modules/debug": { + "version": "2.6.9", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "dev": true, "license": "MIT", - "peer": true, + "optional": true, "dependencies": { - "tslib": "^1.9.3" - }, - "engines": { - "node": ">= 6.0.0" + "ms": "2.0.0" } }, - "node_modules/tsyringe/node_modules/tslib": { - "version": "1.14.1", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "node_modules/stream-parser/node_modules/ms": { + "version": "2.0.0", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true, - "license": "0BSD", - "peer": true + "license": "MIT", + "optional": true }, - "node_modules/type-check": { - "version": "0.4.0", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "node_modules/string-length": { + "version": "4.0.2", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", "dev": true, "license": "MIT", "dependencies": { - "prelude-ls": "^1.2.1" + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">=10" } }, - "node_modules/type-detect": { - "version": "4.0.8", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "node_modules/string-width": { + "version": "8.3.0", + "integrity": "sha512-ZbmZM0JCihQN91dWnxoipT2KOEyHqEyfRXUyjuRhW8b/xnqPDoq4gWEVApTVa9db2wN8mmoikgFBbjh71+cGeQ==", "dev": true, "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.20.2", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/type-is": { - "version": "2.1.0", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=8" } }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.1.0", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", "engines": { - "node": ">=18" + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.4.0", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" + "ansi-regex": "^6.2.2" }, "engines": { - "node": ">= 0.4" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "node_modules/string.prototype.trim": { + "version": "1.2.11", + "integrity": "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-object-atoms": "^1.1.2", + "has-property-descriptors": "^1.0.2", + "safe-regex-test": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -20873,18 +19977,16 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.5", - "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", + "node_modules/string.prototype.trimend": { + "version": "1.0.10", + "integrity": "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw==", "dev": true, "license": "MIT", "dependencies": { - "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", - "for-each": "^0.3.5", - "gopd": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.10" + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.2" }, "engines": { "node": ">= 0.4" @@ -20893,18 +19995,15 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typed-array-length": { + "node_modules/string.prototype.trimstart": { "version": "1.0.8", - "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.9", - "for-each": "^0.3.5", - "gopd": "^1.2.0", - "is-typed-array": "^1.1.15", - "possible-typed-array-names": "^1.1.0", - "reflect.getprototypeof": "^1.0.10" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -20913,1367 +20012,1215 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/typed-assert": { - "version": "1.0.9", - "integrity": "sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==", + "node_modules/strip-ansi": { + "version": "6.0.1", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", - "peer": true - }, - "node_modules/typedoc": { - "version": "0.28.20", - "integrity": "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==", - "dev": true, - "license": "Apache-2.0", "dependencies": { - "@gerrit0/mini-shiki": "^3.23.0", - "lunr": "^2.3.9", - "markdown-it": "^14.3.0", - "minimatch": "^10.2.5", - "yaml": "^2.9.0" - }, - "bin": { - "typedoc": "bin/typedoc" + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">= 18", - "pnpm": ">= 10" - }, - "peerDependencies": { - "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" + "node": ">=8" } }, - "node_modules/typescript": { - "version": "6.0.3", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" }, "engines": { - "node": ">=14.17" + "node": ">=8" } }, - "node_modules/uc.micro": { - "version": "2.1.0", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "node_modules/strip-bom": { + "version": "4.0.0", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=8" + } }, - "node_modules/uglify-js": { - "version": "3.19.3", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "node_modules/strip-final-newline": { + "version": "2.0.0", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, + "license": "MIT", "engines": { - "node": ">=0.8.0" + "node": ">=6" } }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "node_modules/strip-json-comments": { + "version": "3.1.1", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, "engines": { - "node": ">= 0.4" + "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/undici": { - "version": "8.10.2", - "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "node_modules/supports-color": { + "version": "7.2.0", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, "engines": { - "node": ">=22.19.0" + "node": ">=8" } }, - "node_modules/undici-types": { - "version": "6.21.0", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.1", - "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=4" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/unicode-match-property-ecmascript": { - "version": "2.0.0", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "node_modules/symbol-tree": { + "version": "3.2.4", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sync-child-process": { + "version": "1.0.2", + "integrity": "sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" + "sync-message-port": "^1.0.0" }, "engines": { - "node": ">=4" + "node": ">=16.0.0" } }, - "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.1", - "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "node_modules/sync-message-port": { + "version": "1.2.0", + "integrity": "sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">=4" + "node": ">=16.0.0" } }, - "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.2.0", - "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "node_modules/synckit": { + "version": "0.11.13", + "integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==", "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "@pkgr/core": "^0.3.6" + }, "engines": { - "node": ">=4" + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "node_modules/tapable": { + "version": "2.3.3", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">= 0.8" - } - }, - "node_modules/unrs-resolver": { - "version": "1.12.2", - "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "napi-postinstall": "^0.3.4" + "node": ">=6" }, "funding": { - "url": "https://opencollective.com/unrs-resolver" - }, - "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.12.2", - "@unrs/resolver-binding-android-arm64": "1.12.2", - "@unrs/resolver-binding-darwin-arm64": "1.12.2", - "@unrs/resolver-binding-darwin-x64": "1.12.2", - "@unrs/resolver-binding-freebsd-x64": "1.12.2", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", - "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", - "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", - "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", - "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", - "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", - "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", - "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", - "@unrs/resolver-binding-linux-x64-musl": "1.12.2", - "@unrs/resolver-binding-openharmony-arm64": "1.12.2", - "@unrs/resolver-binding-wasm32-wasi": "1.12.2", - "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", - "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", - "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/update-browserslist-db": { - "version": "1.3.3", - "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "node_modules/terser": { + "version": "5.51.2", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", + "license": "BSD-2-Clause", + "peer": true, "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" }, "bin": { - "update-browserslist-db": "cli.js" + "terser": "bin/terser" }, - "peerDependencies": { - "browserslist": ">= 4.21.0" + "engines": { + "node": ">=10" } }, - "node_modules/uri-js": { - "version": "4.4.1", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "peer": true + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", "dependencies": { - "punycode": "^2.1.0" + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" } }, - "node_modules/util-deprecate": { + "node_modules/test-exclude/node_modules/balanced-match": { "version": "1.0.2", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "2.1.7", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4.0" + "dependencies": { + "balanced-match": "^1.0.0" } }, - "node_modules/uuid": { - "version": "8.3.2", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "node_modules/test-exclude/node_modules/glob": { + "version": "10.5.0", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, - "license": "MIT", - "peer": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, "bin": { - "uuid": "dist/bin/uuid" + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/v8-to-istanbul": { - "version": "9.3.0", - "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "node_modules/test-exclude/node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" + "brace-expansion": "^2.0.2" }, "engines": { - "node": ">=10.12.0" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/v8-to-istanbul/node_modules/convert-source-map": { - "version": "2.0.0", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "10.4.3", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "MIT" + "license": "ISC" }, - "node_modules/validate-npm-package-name": { - "version": "8.0.0", - "integrity": "sha512-SCv6OOV6Xj2/3cXy3dGmADluJTNcL3o7hZAglNPTe+WYuEuvxgJzxPrSDLZhF+CwyQOubqgecjMmTJGMVLWjYQ==", + "node_modules/test-exclude/node_modules/path-scurry": { + "version": "1.11.1", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, "engines": { - "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/vary": { - "version": "1.1.2", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "node_modules/text-table": { + "version": "0.2.0", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "license": "MIT" }, - "node_modules/verkit": { - "version": "0.3.2", - "integrity": "sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==", + "node_modules/thingies": { + "version": "2.6.1", + "integrity": "sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">=18.12.0" + "node": ">=10.18" }, "funding": { - "url": "https://github.com/sponsors/sxzz" + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "^2" } }, - "node_modules/vite": { - "version": "8.1.5", - "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "node_modules/thunky": { + "version": "1.1.0", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.17", - "rolldown": "~1.1.5", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=12.0.0" }, "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/@emnapi/core": { - "version": "1.11.1", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" + "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/vite/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "node_modules/tldts": { + "version": "7.4.16", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", "dev": true, "license": "MIT", - "optional": true, + "peer": true, "dependencies": { - "tslib": "^2.4.0" + "tldts-core": "^7.4.16" + }, + "bin": { + "tldts": "bin/cli.js" } }, - "node_modules/vite/node_modules/@oxc-project/types": { - "version": "0.139.0", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", + "node_modules/tldts-core": { + "version": "7.4.16", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } + "peer": true }, - "node_modules/vite/node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", - "cpu": [ - "arm64" - ], + "node_modules/to-regex-range": { + "version": "5.0.1", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "peer": true, + "dependencies": { + "is-number": "^7.0.0" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=8.0" } }, - "node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", - "cpu": [ - "arm64" - ], + "node_modules/toidentifier": { + "version": "1.0.1", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=0.6" } }, - "node_modules/vite/node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", - "cpu": [ - "x64" - ], + "node_modules/tough-cookie": { + "version": "6.0.2", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "tldts": "^7.0.5" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=16" } }, - "node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", - "cpu": [ - "x64" - ], + "node_modules/tr46": { + "version": "6.0.0", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "peer": true, + "dependencies": { + "punycode": "^2.3.1" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20" } }, - "node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", - "cpu": [ - "arm" - ], + "node_modules/tree-dump": { + "version": "1.1.0", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", + "peer": true, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" } }, - "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", - "cpu": [ - "arm64" - ], + "node_modules/ts-api-utils": { + "version": "2.5.0", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" } }, - "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", - "cpu": [ - "arm64" - ], + "node_modules/ts-jest": { + "version": "29.4.14", + "integrity": "sha512-+943G6wCX5E3VpzTQhBGBFntCmdT+QAkTvUlp03edTONFBvqskuXPhpyX16xdUotO4P5HQKyppldSWjocoltLQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.8.5", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <9", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } } }, - "node_modules/vite/node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", - "cpu": [ - "ppc64" - ], + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "(MIT OR CC0-1.0)", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/vite/node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", - "cpu": [ - "s390x" - ], + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" } }, - "node_modules/vite/node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", - "cpu": [ - "x64" - ], + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" } }, - "node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", - "cpu": [ - "x64" - ], + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=4" } }, - "node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", - "cpu": [ - "arm64" - ], + "node_modules/tslib": { + "version": "2.8.1", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], + "peer": true, + "dependencies": { + "tslib": "^1.9.3" + }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 6.0.0" } }, - "node_modules/vite/node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD", + "peer": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" + "prelude-ls": "^1.2.1" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 0.8.0" } }, - "node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", - "cpu": [ - "arm64" - ], + "node_modules/type-detect": { + "version": "4.0.8", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=4" } }, - "node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", - "cpu": [ - "x64" - ], + "node_modules/type-fest": { + "version": "0.20.2", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], + "license": "(MIT OR CC0-1.0)", "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/vite/node_modules/rolldown": { - "version": "1.1.5", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "node_modules/type-is": { + "version": "2.1.0", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.139.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">= 18" }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "dev": true, "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/watchpack": { - "version": "2.5.2", - "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.1.2" + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" }, "engines": { - "node": ">=10.13.0" + "node": ">= 0.4" } }, - "node_modules/wbuf": { - "version": "1.7.3", - "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "minimalistic-assert": "^1.0.0" - } - }, - "node_modules/weak-lru-cache": { - "version": "1.2.2", - "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, "engines": { - "node": ">=20" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/webpack": { - "version": "5.109.2", - "integrity": "sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==", + "node_modules/typed-array-byte-offset": { + "version": "1.0.5", + "integrity": "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.16.0", - "browserslist": "^4.28.1", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.24.4", - "es-module-lexer": "^2.1.0", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "graceful-fs": "^4.2.11", - "mime-db": "^1.54.0", - "minimizer-webpack-plugin": "^5.6.1", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.3", - "tapable": "^2.3.0", - "watchpack": "^2.5.2", - "webpack-sources": "^3.5.1" - }, - "bin": { - "webpack": "bin/webpack.js" + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.10" }, "engines": { - "node": ">=10.13.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/webpack-dev-middleware": { - "version": "8.0.3", - "integrity": "sha512-zWrde9VZDiRaFuWsjHO40wm9LxxtXEk8DdzFXdU7eU5ZpiANnZZDBbZgN3guxbEoKqUHd9YupBmynyioz42nkA==", + "node_modules/typed-array-length": { + "version": "1.0.8", + "integrity": "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "memfs": "^4.56.10", - "mime-types": "^3.0.2", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "schema-utils": "^4.3.3" + "call-bind": "^1.0.9", + "for-each": "^0.3.5", + "gopd": "^1.2.0", + "is-typed-array": "^1.1.15", + "possible-typed-array-names": "^1.1.0", + "reflect.getprototypeof": "^1.0.10" }, "engines": { - "node": ">= 20.9.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.101.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - } + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/webpack-dev-server": { - "version": "5.2.6", - "integrity": "sha512-HNLRmamRvVavZQ+avceZifmv8hmdUjg43t6MI4SqJDwFdW7RPQwH5vzGhDRZSX59SgfbeHhLnq3g+uooWo7pVw==", + "node_modules/typed-assert": { + "version": "1.0.9", + "integrity": "sha512-KNNZtayBCtmnNmbo5mG47p1XsCyrx6iVqomjcZnec/1Y5GGARaxPs6r49RnSPeUP3YjNYiU9sQHAtY4BBvnZwg==", "dev": true, "license": "MIT", - "peer": true, + "peer": true + }, + "node_modules/typedoc": { + "version": "0.28.20", + "integrity": "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@types/bonjour": "^3.5.13", - "@types/connect-history-api-fallback": "^1.5.4", - "@types/express": "^4.17.25", - "@types/express-serve-static-core": "^4.17.21", - "@types/serve-index": "^1.9.4", - "@types/serve-static": "^1.15.5", - "@types/sockjs": "^0.3.36", - "@types/ws": "^8.5.10", - "ansi-html-community": "^0.0.8", - "bonjour-service": "^1.2.1", - "chokidar": "^3.6.0", - "colorette": "^2.0.10", - "compression": "^1.8.1", - "connect-history-api-fallback": "^2.0.0", - "express": "^4.22.1", - "graceful-fs": "^4.2.6", - "http-proxy-middleware": "^2.0.9", - "ipaddr.js": "^2.1.0", - "launch-editor": "^2.14.1", - "open": "^10.0.3", - "p-retry": "^6.2.0", - "schema-utils": "^4.2.0", - "selfsigned": "^5.5.0", - "serve-index": "^1.9.1", - "sockjs": "^0.3.24", - "spdy": "^4.0.2", - "webpack-dev-middleware": "^7.4.2", - "ws": "^8.18.0" + "@gerrit0/mini-shiki": "^3.23.0", + "lunr": "^2.3.9", + "markdown-it": "^14.3.0", + "minimatch": "^10.2.5", + "yaml": "^2.9.0" }, "bin": { - "webpack-dev-server": "bin/webpack-dev-server.js" + "typedoc": "bin/typedoc" }, "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "node": ">= 18", + "pnpm": ">= 10" }, "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - }, - "webpack-cli": { - "optional": true - } + "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" } }, - "node_modules/webpack-dev-server/node_modules/accepts": { - "version": "1.3.8", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "node_modules/typescript": { + "version": "6.0.3", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">= 0.6" + "node": ">=14.17" } }, - "node_modules/webpack-dev-server/node_modules/accepts/node_modules/mime-types": { - "version": "2.1.35", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/uc.micro": { + "version": "2.1.0", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } + "license": "MIT" }, - "node_modules/webpack-dev-server/node_modules/body-parser": { - "version": "1.20.8", - "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==", + "node_modules/uglify-js": { + "version": "3.19.3", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.16.0", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">=0.8.0" } }, - "node_modules/webpack-dev-server/node_modules/chokidar": { - "version": "3.6.0", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "node_modules/unbox-primitive": { + "version": "1.1.0", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 0.4" }, "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/webpack-dev-server/node_modules/content-disposition": { - "version": "0.5.4", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "node_modules/undici": { + "version": "8.11.2", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", "dev": true, "license": "MIT", "peer": true, - "dependencies": { - "safe-buffer": "5.2.1" - }, "engines": { - "node": ">= 0.6" + "node": ">=22.19.0" } }, - "node_modules/webpack-dev-server/node_modules/cookie-signature": { - "version": "1.0.7", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "node_modules/undici-types": { + "version": "6.21.0", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, - "node_modules/webpack-dev-server/node_modules/debug": { - "version": "2.6.9", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", "dev": true, "license": "MIT", "peer": true, - "dependencies": { - "ms": "2.0.0" + "engines": { + "node": ">=4" } }, - "node_modules/webpack-dev-server/node_modules/debug/node_modules/ms": { + "node_modules/unicode-match-property-ecmascript": { "version": "2.0.0", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/webpack-dev-server/node_modules/express": { - "version": "4.22.3", - "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.13", - "proxy-addr": "~2.0.7", - "qs": "~6.16.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" }, "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=4" } }, - "node_modules/webpack-dev-server/node_modules/finalhandler": { - "version": "1.3.2", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", "dev": true, "license": "MIT", "peer": true, - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, "engines": { - "node": ">= 0.8" + "node": ">=4" } }, - "node_modules/webpack-dev-server/node_modules/fresh": { - "version": "0.5.2", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", "dev": true, "license": "MIT", "peer": true, "engines": { - "node": ">= 0.6" + "node": ">=4" } }, - "node_modules/webpack-dev-server/node_modules/glob-parent": { - "version": "5.1.2", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "node_modules/unpipe": { + "version": "1.0.0", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "dev": true, - "license": "ISC", - "peer": true, - "dependencies": { - "is-glob": "^4.0.1" - }, + "license": "MIT", "engines": { - "node": ">= 6" + "node": ">= 0.8" } }, - "node_modules/webpack-dev-server/node_modules/http-proxy-middleware": { - "version": "2.0.10", - "integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==", + "node_modules/unrs-resolver": { + "version": "1.12.2", + "integrity": "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { - "@types/http-proxy": "^1.17.8", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.1", - "is-plain-obj": "^3.0.0", - "micromatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" + "napi-postinstall": "^0.3.4" }, - "peerDependencies": { - "@types/express": "^4.17.13" + "funding": { + "url": "https://opencollective.com/unrs-resolver" }, - "peerDependenciesMeta": { - "@types/express": { - "optional": true - } + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.12.2", + "@unrs/resolver-binding-android-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-arm64": "1.12.2", + "@unrs/resolver-binding-darwin-x64": "1.12.2", + "@unrs/resolver-binding-freebsd-x64": "1.12.2", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", + "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", + "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", + "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", + "@unrs/resolver-binding-linux-x64-musl": "1.12.2", + "@unrs/resolver-binding-openharmony-arm64": "1.12.2", + "@unrs/resolver-binding-wasm32-wasi": "1.12.2", + "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", + "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", + "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" } }, - "node_modules/webpack-dev-server/node_modules/iconv-lite": { - "version": "0.4.24", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", - "peer": true, "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "escalade": "^3.2.0", + "picocolors": "^1.1.1" }, - "engines": { - "node": ">=0.10.0" + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" } }, - "node_modules/webpack-dev-server/node_modules/ipaddr.js": { - "version": "2.5.0", - "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "node_modules/uri-js": { + "version": "4.4.1", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true, "license": "MIT", - "peer": true, + "peer": true + }, + "node_modules/uuid": { + "version": "9.0.1", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, "engines": { - "node": ">= 10" + "node": ">=10.12.0" } }, - "node_modules/webpack-dev-server/node_modules/is-plain-obj": { - "version": "3.0.0", - "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "node_modules/v8-to-istanbul/node_modules/convert-source-map": { + "version": "2.0.0", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, - "license": "MIT", - "peer": true, + "license": "MIT" + }, + "node_modules/validate-npm-package-name": { + "version": "8.0.0", + "integrity": "sha512-SCv6OOV6Xj2/3cXy3dGmADluJTNcL3o7hZAglNPTe+WYuEuvxgJzxPrSDLZhF+CwyQOubqgecjMmTJGMVLWjYQ==", + "dev": true, + "license": "ISC", "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, - "node_modules/webpack-dev-server/node_modules/media-typer": { - "version": "0.3.0", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "node_modules/varint": { + "version": "6.0.0", + "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "dev": true, "license": "MIT", - "peer": true, "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, - "node_modules/webpack-dev-server/node_modules/merge-descriptors": { - "version": "1.0.3", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "node_modules/verkit": { + "version": "0.3.2", + "integrity": "sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==", "dev": true, "license": "MIT", - "peer": true, + "engines": { + "node": ">=18.12.0" + }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/sxzz" } }, - "node_modules/webpack-dev-server/node_modules/mime-db": { - "version": "1.52.0", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "node_modules/vite": { + "version": "8.3.0", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, "engines": { - "node": ">= 0.6" + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "node_modules/webpack-dev-server/node_modules/negotiator": { - "version": "0.6.3", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", "dev": true, "license": "MIT", "peer": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, "engines": { - "node": ">= 0.6" + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" } }, - "node_modules/webpack-dev-server/node_modules/open": { - "version": "10.2.0", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "node_modules/watchpack": { + "version": "2.5.2", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" + "graceful-fs": "^4.1.2" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=10.13.0" } }, - "node_modules/webpack-dev-server/node_modules/path-to-regexp": { - "version": "0.1.13", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "node_modules/weak-lru-cache": { + "version": "1.2.2", + "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", "dev": true, "license": "MIT", - "peer": true + "optional": true }, - "node_modules/webpack-dev-server/node_modules/picomatch": { - "version": "2.3.2", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } + "node_modules/web-vitals": { + "version": "4.2.4", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" }, - "node_modules/webpack-dev-server/node_modules/range-parser": { - "version": "1.2.1", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "node_modules/webidl-conversions": { + "version": "8.0.1", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "peer": true, "engines": { - "node": ">= 0.6" + "node": ">=20" } }, - "node_modules/webpack-dev-server/node_modules/raw-body": { - "version": "2.5.3", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "node_modules/webpack": { + "version": "5.111.0", + "integrity": "sha512-A2R74kfE6b3eLKS91iZiol03Ebx7avmJJBOyiP+4LePW9NNeNJGQkKeSQos4dk6R4H0YAMYlv+aOrs1CyWT3eA==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.25.0", + "es-module-lexer": "^2.1.0", + "events": "^3.2.0", + "graceful-fs": "^4.2.11", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.7.0", + "schema-utils": "^4.5.0", + "tapable": "^2.3.0", + "watchpack": "^2.5.2", + "webpack-sources": "^3.5.1" }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/webpack-dev-server/node_modules/readdirp": { - "version": "3.6.0", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "picomatch": "^2.2.1" + "bin": { + "webpack": "bin/webpack.js" }, "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/webpack-dev-server/node_modules/send": { - "version": "0.19.2", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" + "node": ">=10.13.0" }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/webpack-dev-server/node_modules/serve-static": { - "version": "1.16.3", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" }, - "engines": { - "node": ">= 0.8.0" + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } } }, - "node_modules/webpack-dev-server/node_modules/type-is": { - "version": "1.6.18", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "node_modules/webpack-dev-middleware": { + "version": "8.3.0", + "integrity": "sha512-jFNNMB29ugmkEUFW9cpWNqcWFQNwuMTMVoRpKVAiz/5nh3gA4x9x6DRPhNHGc/SLre7pEP9yrAPDAmCvdCblKg==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "ansi-html-community": "^0.0.8", + "memfs": "^4.68.2", + "mime-types": "^3.0.2", + "range-parser": "^1.3.0", + "schema-utils": "^4.3.3" }, "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpack-dev-server/node_modules/type-is/node_modules/mime-types": { - "version": "2.1.35", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "mime-db": "1.52.0" + "node": ">= 20.9.0" }, - "engines": { - "node": ">= 0.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.101.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } } }, - "node_modules/webpack-dev-server/node_modules/webpack-dev-middleware": { - "version": "7.4.6", - "integrity": "sha512-yBWCMvIfUmuhAE8vdqUKzH0vg9kuWN0KeG4vBnqRplUFHRU7lMQjkiJWxVQzvo2BTewqhPhDlMB41rAt2jVA9A==", + "node_modules/webpack-dev-server": { + "version": "6.0.0", + "integrity": "sha512-q9SD4ItOGhZLeU6EGT10caDZdHjF50Pz1DtkRZZOPsfluMXOkacWKKOtSBSLVkPqKiF67eFUC0rI88U/tSFPEw==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "colorette": "^2.0.10", - "memfs": "^4.43.1", - "mime-types": "^3.0.1", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "schema-utils": "^4.0.0" + "@types/bonjour": "^3.5.13", + "@types/connect-history-api-fallback": "^1.5.4", + "@types/express": "^5.0.6", + "@types/express-serve-static-core": "^5.1.1", + "@types/serve-index": "^1.9.4", + "@types/serve-static": "^2.2.0", + "@types/ws": "^8.18.1", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.3.0", + "chokidar": "^5.0.0", + "compression": "^1.8.1", + "connect-history-api-fallback": "^2.0.0", + "express": "^5.2.1", + "graceful-fs": "^4.2.11", + "http-proxy-middleware": "^4.1.1", + "ipaddr.js": "^2.3.0", + "launch-editor": "^2.14.1", + "open": "^11.0.0", + "p-retry": "^8.0.0", + "schema-utils": "^4.3.3", + "selfsigned": "^5.5.0", + "serve-index": "^1.9.2", + "tinyglobby": "^0.2.15", + "webpack-dev-middleware": "^8.0.3", + "ws": "^8.20.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" }, "engines": { - "node": ">= 18.12.0" + "node": ">= 22.15.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "webpack": "^5.0.0" + "webpack": "^5.101.0" }, "peerDependenciesMeta": { "webpack": { "optional": true + }, + "webpack-cli": { + "optional": true } } }, - "node_modules/webpack-dev-server/node_modules/wsl-utils": { - "version": "0.1.0", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "node_modules/webpack-dev-server/node_modules/ipaddr.js": { + "version": "2.5.0", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", "dev": true, "license": "MIT", "peer": true, - "dependencies": { - "is-wsl": "^3.1.0" - }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 10" } }, "node_modules/webpack-merge": { @@ -22292,8 +21239,8 @@ } }, "node_modules/webpack-sources": { - "version": "3.5.1", - "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", + "version": "3.6.0", + "integrity": "sha512-EIMmPVvNI0CYXyRPp34F2Qk7W37/BZVZIofS6LAUnrNjCVahS+hO4mxwFJDjQjODTGZDtVzLYoN2+1dYkhk9qA==", "dev": true, "license": "MIT", "peer": true, @@ -22323,55 +21270,6 @@ } } }, - "node_modules/webpack/node_modules/eslint-scope": { - "version": "5.1.1", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/webpack/node_modules/estraverse": { - "version": "4.3.0", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/websocket-driver": { - "version": "0.7.5", - "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/whatwg-encoding": { "version": "3.1.1", "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", @@ -22408,8 +21306,8 @@ } }, "node_modules/whatwg-url": { - "version": "17.1.1", - "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==", + "version": "17.1.2", + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", "dev": true, "license": "MIT", "peer": true, @@ -22545,8 +21443,8 @@ "license": "MIT" }, "node_modules/wrap-ansi": { - "version": "10.0.1", - "integrity": "sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q==", + "version": "10.0.2", + "integrity": "sha512-6OLRNZqRntVGBm26ghZ5eZhCuWWyOkRCSn8XsOIuq5stpCE/tjLbdozd93naILZig0lmD8ANfRwnhn0DLeXGlg==", "dev": true, "license": "MIT", "dependencies": { @@ -22639,8 +21537,8 @@ } }, "node_modules/ws": { - "version": "8.21.3", - "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "version": "8.22.0", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "dev": true, "license": "MIT", "engines": { @@ -22660,14 +21558,14 @@ } }, "node_modules/wsl-utils": { - "version": "0.3.1", - "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "version": "1.0.1", + "integrity": "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==", "dev": true, "license": "MIT", "peer": true, "dependencies": { "is-wsl": "^3.1.0", - "powershell-utils": "^0.1.0" + "powershell-utils": "^0.2.0" }, "engines": { "node": ">=20" @@ -22699,6 +21597,12 @@ "dev": true, "license": "MIT" }, + "node_modules/xxhash-wasm": { + "version": "1.1.0", + "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", @@ -22747,6 +21651,15 @@ } }, "node_modules/yargs-parser": { + "version": "21.1.1", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { "version": "22.0.0", "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", "dev": true, @@ -22779,6 +21692,62 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yuku-ast": { + "version": "0.10.2", + "integrity": "sha512-UnG9mA6giglCvSErft2/40TVFX750Sj1xgwddPLpG7J7rlr/P1wPADg9G2RK+d/1tNLtRVoLdMMOMVBB9561TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "^0.10.2" + } + }, + "node_modules/yuku-codegen": { + "version": "0.10.2", + "integrity": "sha512-hentl2dtrF6cPjiAirtkfxfFZBA6Hdm61UqRJ7cA0qrlDNMk9PZGOjw5w1mx3E0hu/6pHcJF4dJW+D0SXHPZOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "^0.10.2" + }, + "optionalDependencies": { + "@yuku-codegen/binding-android-arm64": "0.10.2", + "@yuku-codegen/binding-darwin-arm64": "0.10.2", + "@yuku-codegen/binding-darwin-x64": "0.10.2", + "@yuku-codegen/binding-freebsd-x64": "0.10.2", + "@yuku-codegen/binding-linux-arm-gnu": "0.10.2", + "@yuku-codegen/binding-linux-arm-musl": "0.10.2", + "@yuku-codegen/binding-linux-arm64-gnu": "0.10.2", + "@yuku-codegen/binding-linux-arm64-musl": "0.10.2", + "@yuku-codegen/binding-linux-x64-gnu": "0.10.2", + "@yuku-codegen/binding-linux-x64-musl": "0.10.2", + "@yuku-codegen/binding-win32-arm64": "0.10.2", + "@yuku-codegen/binding-win32-x64": "0.10.2" + } + }, + "node_modules/yuku-parser": { + "version": "0.10.2", + "integrity": "sha512-CgaU0/PPjCAIEZ3WQroosOxTY3eeKldAN3h+vk8pMNz4+jl1CZzBR4pW+K/rRPF112VooCL5FdjJoiMNjDrL2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yuku-toolchain/types": "^0.10.2", + "yuku-ast": "^0.10.2" + }, + "optionalDependencies": { + "@yuku-parser/binding-android-arm64": "0.10.2", + "@yuku-parser/binding-darwin-arm64": "0.10.2", + "@yuku-parser/binding-darwin-x64": "0.10.2", + "@yuku-parser/binding-freebsd-x64": "0.10.2", + "@yuku-parser/binding-linux-arm-gnu": "0.10.2", + "@yuku-parser/binding-linux-arm-musl": "0.10.2", + "@yuku-parser/binding-linux-arm64-gnu": "0.10.2", + "@yuku-parser/binding-linux-arm64-musl": "0.10.2", + "@yuku-parser/binding-linux-x64-gnu": "0.10.2", + "@yuku-parser/binding-linux-x64-musl": "0.10.2", + "@yuku-parser/binding-win32-arm64": "0.10.2", + "@yuku-parser/binding-win32-x64": "0.10.2" + } + }, "node_modules/zod": { "version": "4.4.3", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", diff --git a/package.json b/package.json index 3215106e8..5f8de7317 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,14 @@ "ng": "ng", "start": "ng serve", "build": "ng build --configuration=production --project=cps-ui-kit", + "build:telemetry": "npm run build && ng build --configuration=production --project=cps-telemetry", "build:documentation": "ng build --configuration=production --project=composition --base-href '/cps-shared-ui/'", "watch": "ng build cps-ui-kit --watch --configuration development", "test": "ng test", "test:composition": "ng test --project=composition", + "trace:telemetry": "ng test --project=composition --watch=false", "test:cps-ui-kit": "ng test --project=cps-ui-kit", + "test:cps-telemetry": "ng test --project=cps-telemetry", "test:coverage:cps-ui-kit": "COVERAGE_PROJECT=cps-ui-kit ng test --project=cps-ui-kit --coverage --coverage-reporters text-summary --coverage-reporters lcov --json --output-file=./coverage/report.json", "test:coverage:composition": "ng test --project=composition --coverage --coverage-reporters text-summary --coverage-reporters lcov --json --output-file=./coverage/report.json", "lint": "eslint \"**/*.ts\"", @@ -47,6 +50,7 @@ "@angular/router": "^22.1.3", "@e965/xlsx": "^0.20.3", "@types/lodash-es": "^4.17.12", + "aws-rum-web": "^3.2.1", "highlight.js": "^11.12.0", "lodash-es": "^4.17.21", "rxjs": "~7.8.2", diff --git a/playwright/composition/accessibility-composition.spec.ts b/playwright/composition/accessibility-composition.spec.ts index 89fd7b81a..1b9035408 100644 --- a/playwright/composition/accessibility-composition.spec.ts +++ b/playwright/composition/accessibility-composition.spec.ts @@ -223,3 +223,75 @@ test.describe('Composition app shell - sidebar & focus flows', () => { }); }); }); + +// ============================================================================ +// Telemetry diagnostics popup (cps-telemetry/diagnostics) +// ============================================================================ + +test.describe('Accessibility - telemetry diagnostics popup', () => { + async function openWithContent(page: Page) { + await page.goto('/'); + await page.waitForSelector('#main-content'); + await page.keyboard.press('Control+Alt+Shift+Digit8'); + await page.locator('cps-diagnostics-dialog').waitFor(); + // Generate an event, then open everything the popup can show. + await toggle(page).click(); + const expand = page + .locator('cps-diagnostics-section') + .getByRole('button', { name: 'Expand row' }) + .first(); + await expand.click(); + await page + .getByRole('button', { name: 'Add a filter to BI telemetry' }) + .click(); + await waitForAnimationsToFinish(page); + } + + test('open popup, with a row expanded and the filter builder, has no violations', async ({ + page, + makeAxeBuilder + }, testInfo) => { + await openWithContent(page); + const results = await makeAxeBuilder().include('.cps-dialog').analyze(); + await testInfo.attach('diagnostics-popup-accessibility-scan', { + body: JSON.stringify(results, null, 2), + contentType: 'application/json' + }); + expectNoViolations(results.violations); + }); + + test('open popup has no violations at mobile width', async ({ + page, + makeAxeBuilder + }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto('/'); + await page.waitForSelector('#main-content'); + await page.keyboard.press('Control+Alt+Shift+Digit8'); + await page.locator('cps-diagnostics-dialog').waitFor(); + await waitForAnimationsToFinish(page); + const results = await makeAxeBuilder().include('.cps-dialog').analyze(); + expectNoViolations(results.violations); + }); + + test('the popup is named, and focus returns to the app when it closes', async ({ + page + }) => { + await page.goto('/'); + await page.waitForSelector('#main-content'); + const link = page.locator('a[href="/button"]').first(); + await link.focus(); + + await page.keyboard.press('Control+Alt+Shift+Digit8'); + await expect( + page.getByRole('dialog', { name: 'Telemetry diagnostics' }) + ).toBeVisible(); + await expect( + page.getByRole('textbox', { name: 'Search BI telemetry events' }) + ).toBeFocused(); + + await page.keyboard.press('Escape'); + await expect(page.locator('cps-diagnostics-dialog')).toHaveCount(0); + await expect(link).toBeFocused(); + }); +}); diff --git a/playwright/composition/telemetry-diagnostics.spec.ts b/playwright/composition/telemetry-diagnostics.spec.ts new file mode 100644 index 000000000..34a4b4e4c --- /dev/null +++ b/playwright/composition/telemetry-diagnostics.spec.ts @@ -0,0 +1,218 @@ +import { readFileSync } from 'node:fs'; +import { type Page, expect, test } from '@playwright/test'; + +/** + * The telemetry diagnostics popup (cps-telemetry/diagnostics), driven the + * way a developer uses it: the real shortcut, the real app generating + * events beside the non-modal popup, then filter, download, clear, close. + */ + +// Active on every platform, so one combination works in all browsers here. +const SHORTCUT = 'Control+Alt+Shift+Digit8'; + +const popup = (page: Page) => page.locator('cps-diagnostics-dialog'); +const section = (page: Page, title: string) => + page.locator('cps-diagnostics-section', { + has: page.locator('[data-testid="cps-expansion-panel-title"]', { + hasText: title + }) + }); +const rows = (page: Page, title: string) => + section(page, title).locator('td.cps-diagnostics-section__time'); + +async function openPopup(page: Page) { + await page.keyboard.press(SHORTCUT); + await expect(popup(page)).toBeVisible(); +} + +test.describe('Telemetry diagnostics popup', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.waitForSelector('#main-content'); + }); + + test('opens on the shortcut, empty, with focus in the search field', async ({ + page + }) => { + // Activity before opening must not appear: history starts at open. + await page + .getByRole('button', { name: 'Toggle navigation sidebar' }) + .click(); + await page + .getByRole('button', { name: 'Toggle navigation sidebar' }) + .click(); + + await openPopup(page); + + await expect(page.getByText('Capturing from now.')).toBeVisible(); + await expect(rows(page, 'BI telemetry')).toHaveCount(0); + await expect( + page.getByRole('textbox', { name: 'Search BI telemetry events' }) + ).toBeFocused(); + }); + + test('shows events from all three concerns live, while the app stays usable', async ({ + page + }) => { + await openPopup(page); + + // The popup is non-modal: these are ordinary clicks on the app. + await page.locator('a[href="/button"]').first().click(); + await page + .getByRole('button', { name: 'Toggle navigation sidebar' }) + .click(); + + await expect(rows(page, 'Scenario telemetry').first()).toBeVisible(); + await expect(section(page, 'Scenario telemetry')).toContainText( + 'route-navigation' + ); + await expect(section(page, 'BI telemetry')).toContainText( + 'sidebar_toggled' + ); + }); + + test('shows log records as they are handed to the log provider', async ({ + page, + browserName + }) => { + test.skip( + browserName === 'webkit', + "WebKit grants clipboard writes, so the app's copy-failure log can't be triggered" + ); + await page.locator('a[href="/button"]').first().click(); + await page.waitForSelector('app-code-example'); + await openPopup(page); + + // Clipboard access is not granted, so copying fails and the app logs a + // warning through its `docs` logger. The button can sit under the + // popup, so it is activated directly rather than by pointer position. + await page + .locator('app-code-example button:has-text("HTML")') + .first() + .dispatchEvent('click'); + await page + .locator('.code-example__copy:visible button') + .first() + .dispatchEvent('click'); + + const logging = section(page, 'Logging'); + await expect(logging).toContainText('Failed to copy code to clipboard'); + await expect(logging).toContainText('docs'); + await expect(logging).toContainText('warn'); + }); + + test('shows the full payload of an expanded row', async ({ page }) => { + await openPopup(page); + await page + .getByRole('button', { name: 'Toggle navigation sidebar' }) + .click(); + + const bi = section(page, 'BI telemetry'); + await bi.getByRole('button', { name: 'Expand row' }).first().click(); + + const payload = bi.locator('pre').first(); + await expect(payload).toBeVisible(); + expect(JSON.parse((await payload.textContent()) ?? '')).toMatchObject({ + eventName: 'sidebar_toggled', + application: 'composition' + }); + }); + + test('filters one section by any field, leaving the others alone', async ({ + page + }) => { + await openPopup(page); + await page.locator('a[href="/button"]').first().click(); + await page + .getByRole('button', { name: 'Toggle navigation sidebar' }) + .click(); + await expect(rows(page, 'BI telemetry')).toHaveCount(1); + await expect(rows(page, 'Scenario telemetry').first()).toBeVisible(); + + await page + .getByRole('textbox', { name: 'Search Scenario telemetry events' }) + .fill('no scenario has this text'); + + await expect(rows(page, 'Scenario telemetry')).toHaveCount(0); + await expect( + section(page, 'Scenario telemetry').locator( + '[data-testid="cps-expansion-panel-title"]' + ) + ).toContainText(' of '); + // BI keeps its own, empty filters. + await expect(rows(page, 'BI telemetry')).toHaveCount(1); + }); + + test("downloads one section's events as JSON", async ({ page }) => { + await openPopup(page); + await page.locator('a[href="/button"]').first().click(); + await page + .getByRole('button', { name: 'Toggle navigation sidebar' }) + .click(); + await expect(rows(page, 'BI telemetry')).toHaveCount(1); + // A filter hiding BI's events must not remove them from BI's download. + await page + .getByRole('textbox', { name: 'Search BI telemetry events' }) + .fill('no event has this text'); + await expect(rows(page, 'BI telemetry')).toHaveCount(0); + + const download = page.waitForEvent('download'); + await page + .getByRole('button', { name: /^Download BI telemetry events/ }) + .click(); + const file = await download; + expect(file.suggestedFilename()).toMatch( + /^cps-telemetry-diagnostics-composition-bi-\d{8}-\d{6}\.json$/ + ); + + const doc = JSON.parse(readFileSync(await file.path(), 'utf8')); + expect(doc.format).toBe('cps-telemetry-diagnostics'); + expect(doc.section).toBe('bi'); + expect(doc.app.application).toBe('composition'); + expect(doc.activeFilters.text).toBe('no event has this text'); + const kinds = doc.events.map((e: { kind: string }) => e.kind); + expect(kinds).toEqual(['bi']); + }); + + test('clears history but keeps capturing', async ({ page }) => { + await openPopup(page); + await page + .getByRole('button', { name: 'Toggle navigation sidebar' }) + .click(); + await expect(rows(page, 'BI telemetry')).toHaveCount(1); + + await page.getByRole('button', { name: 'Clear captured events' }).click(); + await expect(rows(page, 'BI telemetry')).toHaveCount(0); + + await page + .getByRole('button', { name: 'Toggle navigation sidebar' }) + .click(); + await expect(rows(page, 'BI telemetry')).toHaveCount(1); + }); + + test('returns focus to the popup, closes, and starts empty when reopened', async ({ + page + }) => { + await openPopup(page); + await page + .getByRole('button', { name: 'Toggle navigation sidebar' }) + .click(); + await expect(rows(page, 'BI telemetry')).toHaveCount(1); + + // Focus is on the app: the shortcut brings it back to the popup. + await page.keyboard.press(SHORTCUT); + await expect( + page.getByRole('textbox', { name: 'Search BI telemetry events' }) + ).toBeFocused(); + + // Focus is in the popup: the shortcut closes it. + await page.keyboard.press(SHORTCUT); + await expect(popup(page)).toHaveCount(0); + + await openPopup(page); + await expect(rows(page, 'BI telemetry')).toHaveCount(0); + + await page.keyboard.press('Escape'); + await expect(popup(page)).toHaveCount(0); + }); +}); diff --git a/projects/composition/src/app/app-routing.module.ts b/projects/composition/src/app/app-routing.module.ts index 660ad4556..b19ece1a2 100644 --- a/projects/composition/src/app/app-routing.module.ts +++ b/projects/composition/src/app/app-routing.module.ts @@ -1,5 +1,6 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes, UrlMatcher, UrlSegment } from '@angular/router'; +import { defaultTabRedirectGuard } from './guards/default-tab-redirect.guard'; const pathMatcher: (path: string) => UrlMatcher = (path: string) => (url) => { if (url.length === 1) { @@ -23,7 +24,7 @@ const pathMatcher: (path: string) => UrlMatcher = (path: string) => (url) => { return null; }; -const routes: Routes = [ +const routeDefs: Routes = [ { path: 'colors', title: 'Color pack', @@ -299,6 +300,10 @@ const routes: Routes = [ { path: '**', redirectTo: 'colors' } ]; +const routes: Routes = routeDefs.map((route) => + route.matcher ? { ...route, canMatch: [defaultTabRedirectGuard] } : route +); + @NgModule({ imports: [RouterModule.forRoot(routes)], exports: [RouterModule] diff --git a/projects/composition/src/app/app.component.spec.ts b/projects/composition/src/app/app.component.spec.ts index 872deb483..6478df997 100644 --- a/projects/composition/src/app/app.component.spec.ts +++ b/projects/composition/src/app/app.component.spec.ts @@ -9,6 +9,13 @@ import { import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { Subject } from 'rxjs'; import { CpsThemeService } from 'cps-ui-kit'; +import { + CPS_LOG_API_PROVIDER, + CpsNoopTelemetrySink, + CpsTelemetrySink, + provideCpsTelemetry +} from 'cps-telemetry'; +import { AppLogApiProvider } from './services/app-log-api.provider'; import { AppComponent } from './app.component'; jest.mock('../../../cps-ui-kit/package.json', () => ({ version: '1.0.0' }), { @@ -64,7 +71,14 @@ describe('AppComponent', () => { firstChild: { snapshot: { routeConfig: { title: 'Button' } } } } }, - { provide: CpsThemeService, useValue: { isDark: signal(false) } } + { provide: CpsThemeService, useValue: { isDark: signal(false) } }, + provideCpsTelemetry({ + application: 'composition-test', + environment: 'test', + version: '0.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useClass: CpsNoopTelemetrySink } ] }).compileComponents(); diff --git a/projects/composition/src/app/app.component.ts b/projects/composition/src/app/app.component.ts index 15c212203..8d6adf2f7 100644 --- a/projects/composition/src/app/app.component.ts +++ b/projects/composition/src/app/app.component.ts @@ -14,6 +14,7 @@ import packageJson from '../../../cps-ui-kit/package.json'; import { NavigationSidebarComponent } from './components/navigation-sidebar/navigation-sidebar.component'; import { distinctUntilChanged, filter, map } from 'rxjs/operators'; import { CpsThemeService } from 'cps-ui-kit'; +import { AppTelemetryService } from './services/app-telemetry.service'; @Component({ selector: 'app-root', @@ -27,6 +28,7 @@ export class AppComponent { private readonly _platformId = inject(PLATFORM_ID); private readonly _document = inject(DOCUMENT); private readonly _themeService = inject(CpsThemeService); + private readonly _appTelemetry = inject(AppTelemetryService); componentTitle = ''; @@ -48,6 +50,8 @@ export class AppComponent { private _router: Router, private _activatedRoute: ActivatedRoute ) { + this._appTelemetry.start(); + if (isPlatformBrowser(this._platformId)) { this._mobileQuery = this._document.defaultView!.matchMedia( '(max-width: 37.5rem)' @@ -104,9 +108,13 @@ export class AppComponent { toggleSidebar() { this.sidebarExpanded = !this.sidebarExpanded; + this._appTelemetry.trackClick('sidebar_toggled', { + expanded: this.sidebarExpanded + }); } onNavLinkClicked() { + this._appTelemetry.markNavigationIntent(); if (this.isMobile) this.sidebarExpanded = false; this.focusMainContent(); } diff --git a/projects/composition/src/app/app.module.ts b/projects/composition/src/app/app.module.ts index b96364c70..88f41ad1c 100644 --- a/projects/composition/src/app/app.module.ts +++ b/projects/composition/src/app/app.module.ts @@ -6,11 +6,22 @@ import { import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { TitleStrategy } from '@angular/router'; import { CpsIconComponent } from 'cps-ui-kit'; +import { CPS_LOG_API_PROVIDER, provideCpsTelemetry } from 'cps-telemetry'; +import { provideCpsTelemetryDiagnostics } from 'cps-telemetry/diagnostics'; +import { + CPS_RUM_CREDENTIALS_PROVIDER, + provideCpsTelemetryRumSink +} from 'cps-telemetry/rum'; +import packageJson from '../../../cps-ui-kit/package.json'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { AppPrefixTitleStrategy } from './app.prefix-title-strategy'; import { NavigationSidebarComponent } from './components/navigation-sidebar/navigation-sidebar.component'; import { ThemeToggleComponent } from './components/theme-toggle/theme-toggle.component'; +import { AppLogApiProvider } from './services/app-log-api.provider'; +import { AppRumCredentialsProvider } from './services/rum-credentials.provider'; +import { resolveDeploymentEnvironment } from './deployment-environment'; +import './services/telemetry.schema'; @NgModule({ declarations: [AppComponent], @@ -23,7 +34,20 @@ import { ThemeToggleComponent } from './components/theme-toggle/theme-toggle.com ThemeToggleComponent ], providers: [ - { provide: TitleStrategy, useClass: AppPrefixTitleStrategy } + { provide: TitleStrategy, useClass: AppPrefixTitleStrategy }, + provideCpsTelemetry({ + application: 'composition', + environment: resolveDeploymentEnvironment(), + version: packageJson.version + }), + provideCpsTelemetryRumSink(), + { + provide: CPS_RUM_CREDENTIALS_PROVIDER, + useExisting: AppRumCredentialsProvider + }, + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + // Telemetry diagnostics popup: ⇧⌥⌘8 on macOS, Ctrl+Alt+Shift+8 elsewhere. + provideCpsTelemetryDiagnostics() // provideClientHydration() ], bootstrap: [AppComponent] diff --git a/projects/composition/src/app/app.prefix-title-strategy.spec.ts b/projects/composition/src/app/app.prefix-title-strategy.spec.ts new file mode 100644 index 000000000..330568fc3 --- /dev/null +++ b/projects/composition/src/app/app.prefix-title-strategy.spec.ts @@ -0,0 +1,91 @@ +import { TestBed } from '@angular/core/testing'; +import { Title } from '@angular/platform-browser'; +import { RouterStateSnapshot, TitleStrategy } from '@angular/router'; +import { + CPS_LOG_API_PROVIDER, + CpsLogRecord, + CpsNoopTelemetrySink, + CpsTelemetrySink, + provideCpsTelemetry +} from 'cps-telemetry'; +import { AppLogApiProvider } from './services/app-log-api.provider'; +import { AppPrefixTitleStrategy } from './app.prefix-title-strategy'; + +describe('AppPrefixTitleStrategy', () => { + let strategy: AppPrefixTitleStrategy; + let title: Title; + + /** Minimal router state; only `url` is read, plus `buildTitle` traversal. */ + function stateWithTitle(routeTitle?: string): RouterStateSnapshot { + return { + url: '/button', + root: { + children: [], + routeConfig: { title: routeTitle }, + title: routeTitle + } + } as unknown as RouterStateSnapshot; + } + + function logs(): CpsLogRecord[] { + return TestBed.inject(AppLogApiProvider).getRecords(); + } + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'composition-test', + environment: 'test', + version: '0.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useClass: CpsNoopTelemetrySink }, + { provide: TitleStrategy, useClass: AppPrefixTitleStrategy } + ] + }); + strategy = TestBed.inject(TitleStrategy) as AppPrefixTitleStrategy; + title = TestBed.inject(Title); + jest.spyOn(title, 'setTitle'); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('should prefix a route title', () => { + jest.spyOn(strategy, 'buildTitle').mockReturnValue('Button'); + + strategy.updateTitle(stateWithTitle('Button')); + + expect(title.setTitle).toHaveBeenCalledWith('CPS UI Kit - Button'); + }); + + it('should fall back to the bare product name without a route title', () => { + jest.spyOn(strategy, 'buildTitle').mockReturnValue(undefined); + + strategy.updateTitle(stateWithTitle()); + + expect(title.setTitle).toHaveBeenCalledWith('CPS UI Kit'); + }); + + it('should not warn when the route has a title', () => { + jest.spyOn(strategy, 'buildTitle').mockReturnValue('Button'); + + strategy.updateTitle(stateWithTitle('Button')); + + expect(logs()).toHaveLength(0); + }); + + it('should warn, naming the route, when the title is missing', () => { + jest.spyOn(strategy, 'buildTitle').mockReturnValue(undefined); + + strategy.updateTitle(stateWithTitle()); + + expect(logs()).toHaveLength(1); + expect(logs()[0]).toMatchObject({ + level: 'warn', + message: 'No title defined for route', + context: 'TitleStrategy', + metadata: { url: '/button' } + }); + }); +}); diff --git a/projects/composition/src/app/app.prefix-title-strategy.ts b/projects/composition/src/app/app.prefix-title-strategy.ts index bc0a0f9bb..d6385b964 100644 --- a/projects/composition/src/app/app.prefix-title-strategy.ts +++ b/projects/composition/src/app/app.prefix-title-strategy.ts @@ -1,16 +1,27 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { TitleStrategy, RouterStateSnapshot } from '@angular/router'; +import { CpsLoggerService } from 'cps-telemetry'; +import './services/telemetry.schema'; @Injectable() export class AppPrefixTitleStrategy extends TitleStrategy { + private readonly logger = inject(CpsLoggerService).getLogger('routing'); + constructor(private readonly title: Title) { super(); } override updateTitle(routerState: RouterStateSnapshot) { const title = this.buildTitle(routerState); - if (!title) console.warn('No title defined for current route!'); + + if (!title) { + this.logger.warn('No title defined for route', { + context: 'TitleStrategy', + metadata: { url: routerState.url } + }); + } + this.title.setTitle(title ? `CPS UI Kit - ${title}` : 'CPS UI Kit'); } } diff --git a/projects/composition/src/app/components/code-example/code-example.component.spec.ts b/projects/composition/src/app/components/code-example/code-example.component.spec.ts index c4be17975..1bdcf8e60 100644 --- a/projects/composition/src/app/components/code-example/code-example.component.spec.ts +++ b/projects/composition/src/app/components/code-example/code-example.component.spec.ts @@ -1,15 +1,35 @@ import { PLATFORM_ID } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; +import { + CPS_LOG_API_PROVIDER, + CpsLogRecord, + CpsNoopTelemetrySink, + CpsTelemetrySink, + provideCpsTelemetry +} from 'cps-telemetry'; +import { AppLogApiProvider } from '../../services/app-log-api.provider'; import { CodeExampleComponent } from './code-example.component'; +/** Telemetry wired to run for real, with nowhere to send anything. */ +const telemetryProviders = [ + provideCpsTelemetry({ + application: 'composition-test', + environment: 'test', + version: '0.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useClass: CpsNoopTelemetrySink } +]; + describe('CodeExampleComponent', () => { let component: CodeExampleComponent; let fixture: ComponentFixture<CodeExampleComponent>; beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [CodeExampleComponent] + imports: [CodeExampleComponent], + providers: [...telemetryProviders] }).compileComponents(); fixture = TestBed.createComponent(CodeExampleComponent); @@ -20,6 +40,43 @@ describe('CodeExampleComponent', () => { jest.restoreAllMocks(); }); + describe('authoring diagnostics', () => { + function logs(): CpsLogRecord[] { + return TestBed.inject(AppLogApiProvider).getRecords(); + } + + it('should warn when neither htmlCode nor tsCode is provided', () => { + fixture.detectChanges(); + + expect(logs()).toHaveLength(1); + expect(logs()[0]).toMatchObject({ + level: 'warn', + message: 'At least one of htmlCode or tsCode must be provided', + context: 'CodeExampleComponent' + }); + }); + + it('should identify which example is misconfigured', () => { + fixture.componentRef.setInput('label', 'Basic usage'); + fixture.detectChanges(); + + expect(logs()[0].metadata).toMatchObject({ + instanceId: component.instanceId, + label: 'Basic usage' + }); + }); + + it.each([ + ['htmlCode', '<div></div>'], + ['tsCode', 'const a = 1;'] + ])('should stay quiet when %s is provided', (input, value) => { + fixture.componentRef.setInput(input, value); + fixture.detectChanges(); + + expect(logs()).toHaveLength(0); + }); + }); + describe('initialisation', () => { it('should create', () => { fixture = TestBed.createComponent(CodeExampleComponent); @@ -371,7 +428,10 @@ describe('CodeExampleComponent', () => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ imports: [CodeExampleComponent], - providers: [{ provide: PLATFORM_ID, useValue: 'server' }] + providers: [ + ...telemetryProviders, + { provide: PLATFORM_ID, useValue: 'server' } + ] }); fixture = TestBed.createComponent(CodeExampleComponent); component = fixture.componentInstance; diff --git a/projects/composition/src/app/components/code-example/code-example.component.ts b/projects/composition/src/app/components/code-example/code-example.component.ts index 29ac2a95d..c9a030741 100644 --- a/projects/composition/src/app/components/code-example/code-example.component.ts +++ b/projects/composition/src/app/components/code-example/code-example.component.ts @@ -15,6 +15,9 @@ import { import { isPlatformBrowser } from '@angular/common'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { CpsButtonComponent } from 'cps-ui-kit'; +import { CpsLoggerService } from 'cps-telemetry'; +import '../../services/telemetry.schema'; +import { AppTelemetryService } from '../../services/app-telemetry.service'; import hljs from 'highlight.js/lib/core'; import xml from 'highlight.js/lib/languages/xml'; import typescript from 'highlight.js/lib/languages/typescript'; @@ -41,6 +44,8 @@ export class CodeExampleComponent { private sanitizer = inject(DomSanitizer); private platformId = inject(PLATFORM_ID); + private logger = inject(CpsLoggerService).getLogger('docs'); + private appTelemetry = inject(AppTelemetryService); instanceId = `code-example-${++CodeExampleComponent.instanceCount}`; activeTab = signal<TabId>('preview'); @@ -71,8 +76,12 @@ export class CodeExampleComponent { const tsCode = this.tsCode(); if (!htmlCode && !tsCode) { - console.warn( - 'CodeExampleComponent: At least one of htmlCode or tsCode must be provided' + this.logger.warn( + 'At least one of htmlCode or tsCode must be provided', + { + context: 'CodeExampleComponent', + metadata: { instanceId: this.instanceId, label: this.label() } + } ); } @@ -140,9 +149,16 @@ export class CodeExampleComponent { try { await navigator.clipboard.writeText(textToCopy); this.copied.set(true); + this.appTelemetry.trackClick('code_copied', { + language: this.activeTab() + }); setTimeout(() => this.copied.set(false), 2000); - } catch { + } catch (error) { this.copyFailed.set(true); + this.logger.warn('Failed to copy code to clipboard', { + context: 'CodeExample', + error + }); setTimeout(() => this.copyFailed.set(false), 2000); } } diff --git a/projects/composition/src/app/components/navigation-sidebar/navigation-sidebar.component.spec.ts b/projects/composition/src/app/components/navigation-sidebar/navigation-sidebar.component.spec.ts index 8b3ab037c..0c7b684da 100644 --- a/projects/composition/src/app/components/navigation-sidebar/navigation-sidebar.component.spec.ts +++ b/projects/composition/src/app/components/navigation-sidebar/navigation-sidebar.component.spec.ts @@ -2,6 +2,14 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { provideNoopAnimations } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; +import { + CPS_LOG_API_PROVIDER, + CpsNoopTelemetrySink, + CpsTelemetrySink, + provideCpsTelemetry +} from 'cps-telemetry'; +import { AppLogApiProvider } from '../../services/app-log-api.provider'; +import { AppTelemetryService } from '../../services/app-telemetry.service'; import { NavigationSidebarComponent } from './navigation-sidebar.component'; describe('NavigationSidebarComponent', () => { @@ -11,7 +19,17 @@ describe('NavigationSidebarComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [NavigationSidebarComponent], - providers: [provideRouter([]), provideNoopAnimations()] + providers: [ + provideRouter([]), + provideNoopAnimations(), + provideCpsTelemetry({ + application: 'composition-test', + environment: 'test', + version: '0.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useClass: CpsNoopTelemetrySink } + ] }).compileComponents(); fixture = TestBed.createComponent(NavigationSidebarComponent); @@ -157,6 +175,35 @@ describe('NavigationSidebarComponent', () => { }); }); + describe('ngOnDestroy', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('should not fire the debounced search report after the component is destroyed', () => { + const appTelemetry = TestBed.inject(AppTelemetryService); + const trackClick = jest.spyOn(appTelemetry, 'trackClick'); + + component.onSearchChanged('button'); + fixture.destroy(); + jest.advanceTimersByTime(400); + + expect(trackClick).not.toHaveBeenCalled(); + }); + + it('should still fire it normally when the component is not destroyed', () => { + const appTelemetry = TestBed.inject(AppTelemetryService); + const trackClick = jest.spyOn(appTelemetry, 'trackClick'); + + component.onSearchChanged('button'); + jest.advanceTimersByTime(400); + + expect(trackClick).toHaveBeenCalledWith( + 'sidebar_searched', + expect.objectContaining({ resultCount: expect.any(Number) }) + ); + }); + }); + describe('template', () => { it('renders nav with Main navigation aria-label', () => { const nav = fixture.debugElement.query(By.css('nav')); diff --git a/projects/composition/src/app/components/navigation-sidebar/navigation-sidebar.component.ts b/projects/composition/src/app/components/navigation-sidebar/navigation-sidebar.component.ts index d9628716e..404e8fab6 100644 --- a/projects/composition/src/app/components/navigation-sidebar/navigation-sidebar.component.ts +++ b/projects/composition/src/app/components/navigation-sidebar/navigation-sidebar.component.ts @@ -1,8 +1,10 @@ import { CommonModule } from '@angular/common'; import { Component, + DestroyRef, ElementRef, EventEmitter, + inject, Input, OnInit, Output, @@ -10,9 +12,13 @@ import { ViewChildren, ChangeDetectionStrategy } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { RouterLinkActive, RouterModule } from '@angular/router'; +import { Subject } from 'rxjs'; +import { debounceTime, filter } from 'rxjs/operators'; import { CpsInputComponent } from 'cps-ui-kit'; +import { AppTelemetryService } from '../../services/app-telemetry.service'; @Component({ imports: [RouterModule, CommonModule, FormsModule, CpsInputComponent], @@ -188,12 +194,31 @@ export class NavigationSidebarComponent implements OnInit { searchVal = ''; + private readonly appTelemetry = inject(AppTelemetryService); + private readonly destroyRef = inject(DestroyRef); + + /** Reports the search once typing pauses, not on every keystroke. */ + private readonly searchTelemetry$ = new Subject<string>(); + ngOnInit(): void { this.filteredComponents = [...this._components]; + + this.searchTelemetry$ + .pipe( + debounceTime(400), + filter((value) => !!value), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => { + this.appTelemetry.trackClick('sidebar_searched', { + resultCount: this.filteredComponents.length + }); + }); } onSearchChanged(value: string) { this._filterComponentsList(value); + this.searchTelemetry$.next(value); } private _filterComponentsList(searchStr: string) { @@ -210,6 +235,8 @@ export class NavigationSidebarComponent implements OnInit { onLinkClick() { if (this.searchVal) { + // Resets the debounce timer so a pending search report doesn't fire after the search is cleared. + this.searchTelemetry$.next(''); this.searchVal = ''; this.filteredComponents = [...this._components]; } diff --git a/projects/composition/src/app/components/theme-toggle/theme-toggle.component.spec.ts b/projects/composition/src/app/components/theme-toggle/theme-toggle.component.spec.ts new file mode 100644 index 000000000..5950ca557 --- /dev/null +++ b/projects/composition/src/app/components/theme-toggle/theme-toggle.component.spec.ts @@ -0,0 +1,134 @@ +import { TestBed } from '@angular/core/testing'; +import { CpsThemeService } from 'cps-ui-kit'; +import { AppTelemetryService } from '../../services/app-telemetry.service'; +import { ThemeToggleComponent } from './theme-toggle.component'; + +describe('ThemeToggleComponent', () => { + let component: ThemeToggleComponent; + let themeService: { + isDark: jest.Mock; + colorTheme: jest.Mock; + radiusTheme: jest.Mock; + baseTheme: jest.Mock; + toggleTheme: jest.Mock; + setColorTheme: jest.Mock; + setRadiusTheme: jest.Mock; + setBaseTheme: jest.Mock; + }; + let appTelemetry: { trackThemeChanged: jest.Mock; trackClick: jest.Mock }; + + beforeEach(() => { + themeService = { + isDark: jest.fn(() => false), + colorTheme: jest.fn(() => 'neutral'), + radiusTheme: jest.fn(() => 'rounded'), + baseTheme: jest.fn(() => 'default'), + toggleTheme: jest.fn(), + setColorTheme: jest.fn(), + setRadiusTheme: jest.fn(), + setBaseTheme: jest.fn() + }; + appTelemetry = { + trackThemeChanged: jest.fn(), + trackClick: jest.fn() + }; + + TestBed.configureTestingModule({ + imports: [ThemeToggleComponent], + providers: [ + { provide: CpsThemeService, useValue: themeService }, + { provide: AppTelemetryService, useValue: appTelemetry } + ] + }); + + component = TestBed.createComponent(ThemeToggleComponent).componentInstance; + }); + + describe('toggleTheme', () => { + it('should toggle the underlying theme and report the mode it switched to', () => { + themeService.isDark.mockReturnValue(true); + + component.toggleTheme(); + + expect(themeService.toggleTheme).toHaveBeenCalled(); + expect(appTelemetry.trackThemeChanged).toHaveBeenCalledWith('dark'); + }); + + it('should report light when the toggle lands back on light mode', () => { + themeService.isDark.mockReturnValue(false); + + component.toggleTheme(); + + expect(appTelemetry.trackThemeChanged).toHaveBeenCalledWith('light'); + }); + + it('should read the resulting mode after toggling, not before', () => { + let toggled = false; + themeService.isDark.mockImplementation(() => toggled); + themeService.toggleTheme.mockImplementation(() => { + toggled = true; + }); + + component.toggleTheme(); + + expect(appTelemetry.trackThemeChanged).toHaveBeenCalledWith('dark'); + }); + }); + + describe('theme dimension changes', () => { + it('should apply and report a color theme change', () => { + component.setColorTheme('passion'); + + expect(themeService.setColorTheme).toHaveBeenCalledWith('passion'); + expect(appTelemetry.trackClick).toHaveBeenCalledWith( + 'theme_option_changed', + { dimension: 'color', value: 'passion' } + ); + }); + + it('should apply and report a radius theme change', () => { + component.setRadiusTheme('pill'); + + expect(themeService.setRadiusTheme).toHaveBeenCalledWith('pill'); + expect(appTelemetry.trackClick).toHaveBeenCalledWith( + 'theme_option_changed', + { dimension: 'radius', value: 'pill' } + ); + }); + + it('should apply and report a base theme change', () => { + component.setBaseTheme('midnight'); + + expect(themeService.setBaseTheme).toHaveBeenCalledWith('midnight'); + expect(appTelemetry.trackClick).toHaveBeenCalledWith( + 'theme_option_changed', + { dimension: 'base', value: 'midnight' } + ); + }); + }); + + describe('menu', () => { + it('should open and close the menu', () => { + expect(component.menuOpen).toBe(false); + + component.toggleMenu(); + expect(component.menuOpen).toBe(true); + + component.closeMenu(); + expect(component.menuOpen).toBe(false); + }); + + it('should close an open menu on Escape', () => { + component.toggleMenu(); + + component.onEscapeKey(); + + expect(component.menuOpen).toBe(false); + }); + + it('should do nothing on Escape when the menu is already closed', () => { + expect(() => component.onEscapeKey()).not.toThrow(); + expect(component.menuOpen).toBe(false); + }); + }); +}); diff --git a/projects/composition/src/app/components/theme-toggle/theme-toggle.component.ts b/projects/composition/src/app/components/theme-toggle/theme-toggle.component.ts index 6c40793b3..90523845f 100644 --- a/projects/composition/src/app/components/theme-toggle/theme-toggle.component.ts +++ b/projects/composition/src/app/components/theme-toggle/theme-toggle.component.ts @@ -6,6 +6,7 @@ import { CpsRadiusTheme, CpsThemeService } from 'cps-ui-kit'; +import { AppTelemetryService } from '../../services/app-telemetry.service'; @Component({ selector: 'app-theme-toggle', @@ -19,6 +20,7 @@ import { }) export class ThemeToggleComponent { private themeService = inject(CpsThemeService); + private appTelemetry = inject(AppTelemetryService); isDark = this.themeService.isDark; colorTheme = this.themeService.colorTheme; @@ -28,6 +30,9 @@ export class ThemeToggleComponent { toggleTheme(): void { this.themeService.toggleTheme(); + this.appTelemetry.trackThemeChanged( + this.themeService.isDark() ? 'dark' : 'light' + ); } toggleMenu(): void { @@ -45,14 +50,26 @@ export class ThemeToggleComponent { } setColorTheme(value: CpsColorTheme): void { - this.themeService.setColorTheme(value); + this._applyTheme('color', value, (v) => this.themeService.setColorTheme(v)); } setRadiusTheme(value: CpsRadiusTheme): void { - this.themeService.setRadiusTheme(value); + this._applyTheme('radius', value, (v) => + this.themeService.setRadiusTheme(v) + ); } setBaseTheme(value: CpsBaseTheme): void { - this.themeService.setBaseTheme(value); + this._applyTheme('base', value, (v) => this.themeService.setBaseTheme(v)); + } + + /** Applies one theme dimension and reports it. */ + private _applyTheme<T extends string | number | boolean | null>( + dimension: string, + value: T, + setter: (value: T) => void + ): void { + setter(value); + this.appTelemetry.trackClick('theme_option_changed', { dimension, value }); } } diff --git a/projects/composition/src/app/components/viewer/viewer.component.spec.ts b/projects/composition/src/app/components/viewer/viewer.component.spec.ts index ed39d369b..70fb24ece 100644 --- a/projects/composition/src/app/components/viewer/viewer.component.spec.ts +++ b/projects/composition/src/app/components/viewer/viewer.component.spec.ts @@ -57,12 +57,9 @@ describe('ViewerComponent', () => { fixture.detectChanges(); }); - it('navigates to ./examples when no type param is present', () => { + it('does not navigate when no type param is present (handled upstream by defaultTabRedirectGuard)', () => { routeParams$.next({}); - expect(mockRouter.navigate).toHaveBeenCalledWith(['./examples'], { - relativeTo: expect.any(Object), - replaceUrl: true - }); + expect(mockRouter.navigate).not.toHaveBeenCalled(); }); it('sets selectedTabIndex to 0 when type is "examples"', () => { @@ -79,7 +76,7 @@ describe('ViewerComponent', () => { ).toBe(1); }); - it('navigates to ./examples and resets selectedTabIndex to 0 on subsequent emission without type (component reuse)', () => { + it('does not navigate or change selectedTabIndex on a subsequent emission without type (component reuse)', () => { routeParams$.next({ type: 'api' }); expect( (component as unknown as { selectedTabIndex: number }).selectedTabIndex @@ -88,10 +85,10 @@ describe('ViewerComponent', () => { mockRouter.navigate.mockClear(); routeParams$.next({}); - expect(mockRouter.navigate).toHaveBeenCalledWith(['./examples'], { - relativeTo: expect.any(Object), - replaceUrl: true - }); + expect(mockRouter.navigate).not.toHaveBeenCalled(); + expect( + (component as unknown as { selectedTabIndex: number }).selectedTabIndex + ).toBe(1); }); }); diff --git a/projects/composition/src/app/components/viewer/viewer.component.ts b/projects/composition/src/app/components/viewer/viewer.component.ts index db615a666..5c52a982c 100644 --- a/projects/composition/src/app/components/viewer/viewer.component.ts +++ b/projects/composition/src/app/components/viewer/viewer.component.ts @@ -27,13 +27,6 @@ export abstract class ViewerComponent implements OnInit, AfterViewInit { this._route.params .pipe(takeUntilDestroyed(this._destroyRef)) .subscribe((params) => { - if (!params.type) { - this._router.navigate(['./examples'], { - relativeTo: this._route, - replaceUrl: true - }); - return; - } if (params.type === 'examples') { this.selectedTabIndex = 0; } else if (params.type === 'api') { diff --git a/projects/composition/src/app/deployment-environment.spec.ts b/projects/composition/src/app/deployment-environment.spec.ts new file mode 100644 index 000000000..0fb613c29 --- /dev/null +++ b/projects/composition/src/app/deployment-environment.spec.ts @@ -0,0 +1,30 @@ +import { + classifyHostname, + resolveDeploymentEnvironment +} from './deployment-environment'; + +describe('classifyHostname', () => { + it.each(['localhost', '127.0.0.1', '[::1]', '0.0.0.0'])( + "should resolve '%s' to development", + (hostname) => { + expect(classifyHostname(hostname)).toBe('development'); + } + ); + + it.each(['app.example.com', 'staging.example.com', '203.0.113.5'])( + "should resolve '%s' to production", + (hostname) => { + expect(classifyHostname(hostname)).toBe('production'); + } + ); + + it('should default to production with no hostname at all', () => { + expect(classifyHostname(undefined)).toBe('production'); + }); +}); + +describe('resolveDeploymentEnvironment', () => { + it('should read the real window.location.hostname', () => { + expect(resolveDeploymentEnvironment()).toBe('development'); + }); +}); diff --git a/projects/composition/src/app/deployment-environment.ts b/projects/composition/src/app/deployment-environment.ts new file mode 100644 index 000000000..cfac39607 --- /dev/null +++ b/projects/composition/src/app/deployment-environment.ts @@ -0,0 +1,15 @@ +const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '0.0.0.0']); + +export function classifyHostname( + hostname: string | undefined +): 'development' | 'production' { + return hostname !== undefined && LOCAL_HOSTNAMES.has(hostname) + ? 'development' + : 'production'; +} + +export function resolveDeploymentEnvironment(): 'development' | 'production' { + return classifyHostname( + typeof window !== 'undefined' ? window.location.hostname : undefined + ); +} diff --git a/projects/composition/src/app/guards/default-tab-redirect.guard.spec.ts b/projects/composition/src/app/guards/default-tab-redirect.guard.spec.ts new file mode 100644 index 000000000..89ac9b273 --- /dev/null +++ b/projects/composition/src/app/guards/default-tab-redirect.guard.spec.ts @@ -0,0 +1,35 @@ +import { TestBed } from '@angular/core/testing'; +import { Route, Router, UrlSegment, UrlTree } from '@angular/router'; +import { defaultTabRedirectGuard } from './default-tab-redirect.guard'; + +describe('defaultTabRedirectGuard', () => { + let mockRouter: { parseUrl: jest.Mock }; + + beforeEach(() => { + mockRouter = { + parseUrl: jest.fn((url: string) => ({ url }) as unknown as UrlTree) + }; + + TestBed.configureTestingModule({ + providers: [{ provide: Router, useValue: mockRouter }] + }); + }); + + function runGuard(segments: string[]) { + const urlSegments = segments.map((path) => new UrlSegment(path, {})); + return TestBed.runInInjectionContext(() => + defaultTabRedirectGuard({} as Route, urlSegments, undefined as never) + ); + } + + it('allows matching when more than one segment is present', () => { + const result = runGuard(['checkbox', 'examples']); + expect(result).toBe(true); + expect(mockRouter.parseUrl).not.toHaveBeenCalled(); + }); + + it('redirects to the examples tab when only the bare segment is present', () => { + runGuard(['checkbox']); + expect(mockRouter.parseUrl).toHaveBeenCalledWith('/checkbox/examples'); + }); +}); diff --git a/projects/composition/src/app/guards/default-tab-redirect.guard.ts b/projects/composition/src/app/guards/default-tab-redirect.guard.ts new file mode 100644 index 000000000..777adaa6f --- /dev/null +++ b/projects/composition/src/app/guards/default-tab-redirect.guard.ts @@ -0,0 +1,12 @@ +import { inject } from '@angular/core'; +import { CanMatchFn, Router } from '@angular/router'; + +export const defaultTabRedirectGuard: CanMatchFn = (_route, segments) => { + if (segments.length !== 1) { + return true; + } + + const router = inject(Router); + const path = segments.map((segment) => segment.path).join('/'); + return router.parseUrl(`/${path}/examples`); +}; diff --git a/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.spec.ts b/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.spec.ts new file mode 100644 index 000000000..5d56d2672 --- /dev/null +++ b/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.spec.ts @@ -0,0 +1,218 @@ +import { TestBed } from '@angular/core/testing'; +import { FormBuilder } from '@angular/forms'; +import { throwError } from 'rxjs'; +import { + CPS_LOG_API_PROVIDER, + CpsNoopTelemetrySink, + CpsScenarioTelemetryService, + CpsTelemetrySink, + provideCpsTelemetry +} from 'cps-telemetry'; +import { AppLogApiProvider } from '../../services/app-log-api.provider'; +import { AutocompletePageComponent } from './autocomplete-page.component'; + +describe('AutocompletePageComponent', () => { + let component: AutocompletePageComponent; + let scenarioTelemetry: CpsScenarioTelemetryService; + + function createComponent(): AutocompletePageComponent { + return TestBed.runInInjectionContext( + () => new AutocompletePageComponent(TestBed.inject(FormBuilder)) + ); + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'composition-test', + environment: 'test', + version: '0.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useClass: CpsNoopTelemetrySink } + ] + }).compileComponents(); + + component = createComponent(); + component.ngOnInit(); + component.singleOptionsObservable$?.subscribe(); + component.multiOptionsObservable$?.subscribe(); + scenarioTelemetry = TestBed.inject(CpsScenarioTelemetryService); + }); + + afterEach(() => jest.restoreAllMocks()); + + describe('search error handling', () => { + function makeSearchThrow(): void { + jest + .spyOn( + component as unknown as { _getOptionsFromServer: unknown }, + '_getOptionsFromServer' + ) + .mockReturnValue(throwError(() => new Error('backend down'))); + } + + it('should not leave the spinner stuck when a search errors', () => { + makeSearchThrow(); + component.onSingleInputChanged('lon'); + + expect(component.isSingleLoading).toBe(false); + }); + + it('should not leave the scenario open when a search errors', () => { + makeSearchThrow(); + component.onSingleInputChanged('lon'); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should settle the scenario as failed, carrying the error', () => { + const settled = jest.fn(); + scenarioTelemetry.settled$.subscribe(settled); + + makeSearchThrow(); + component.onSingleInputChanged('lon'); + + expect(settled).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'failure', + error: expect.objectContaining({ message: 'backend down' }) + }) + ); + }); + + it('should not kill the pipeline for later searches after one errors', () => { + makeSearchThrow(); + component.onSingleInputChanged('lon'); + expect(component.isSingleLoading).toBe(false); + + jest.restoreAllMocks(); + component.onSingleInputChanged('par'); + + expect(component.isSingleLoading).toBe(true); + }); + }); + + describe('onOptionSelected', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('should cancel a still-running validation when a newer selection supersedes it', () => { + const settled = jest.fn(); + scenarioTelemetry.settled$.subscribe(settled); + + component.onOptionSelected(component.options[0]); + expect(scenarioTelemetry.getActive()).toHaveLength(1); + + component.onOptionSelected(component.options[1]); + + expect(scenarioTelemetry.getActive()).toHaveLength(1); + expect(settled).toHaveBeenCalledWith( + expect.objectContaining({ status: 'abandoned', reason: 'superseded' }) + ); + }); + + it("should let ngOnDestroy cancel the newer selection even past the superseded one's original timer mark", () => { + component.onOptionSelected(component.options[0]); + component.onOptionSelected(component.options[1]); + + jest.advanceTimersByTime(3000); + component.ngOnDestroy(); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should settle the newer selection successfully once its own delay elapses', () => { + component.onOptionSelected(component.options[0]); + component.onOptionSelected(component.options[1]); + + jest.advanceTimersByTime(3000); + + expect(component.validating).toBe(false); + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should not leave the validating flag stuck when validation errors', () => { + jest + .spyOn( + component as unknown as { _validateOption: unknown }, + '_validateOption' + ) + .mockReturnValue( + throwError(() => new Error('validation backend down')) + ); + + component.onOptionSelected(component.options[0]); + + expect(component.validating).toBe(false); + expect(component.externalError).toBe('Validation failed'); + }); + + it('should not leave the scenario open when validation errors', () => { + jest + .spyOn( + component as unknown as { _validateOption: unknown }, + '_validateOption' + ) + .mockReturnValue( + throwError(() => new Error('validation backend down')) + ); + + component.onOptionSelected(component.options[0]); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should not kill the pipeline for later selections after one errors', () => { + jest + .spyOn( + component as unknown as { _validateOption: unknown }, + '_validateOption' + ) + .mockReturnValueOnce( + throwError(() => new Error('validation backend down')) + ); + + component.onOptionSelected(component.options[0]); + expect(component.validating).toBe(false); + + component.onOptionSelected(component.options[1]); + expect(component.validating).toBe(true); + }); + }); + + describe('ngOnDestroy', () => { + it('should cancel an in-flight single search, not leave it open', () => { + component.onSingleInputChanged('lon'); + expect(scenarioTelemetry.getActive()).toHaveLength(1); + + component.ngOnDestroy(); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should cancel an in-flight multi search independently of the single one', () => { + component.onSingleInputChanged('lon'); + component.onMultiInputChanged('par'); + expect(scenarioTelemetry.getActive()).toHaveLength(2); + + component.ngOnDestroy(); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should cancel an in-flight selection validation', () => { + component.onOptionSelected(component.options[0]); + expect(scenarioTelemetry.getActive()).toHaveLength(1); + + component.ngOnDestroy(); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should not throw when nothing is in flight', () => { + expect(() => component.ngOnDestroy()).not.toThrow(); + }); + }); +}); diff --git a/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.ts b/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.ts index b21f67591..49c2cf053 100644 --- a/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.ts +++ b/projects/composition/src/app/pages/autocomplete-page/autocomplete-page.component.ts @@ -1,4 +1,10 @@ -import { Component, OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { + Component, + OnInit, + OnDestroy, + ChangeDetectionStrategy, + inject +} from '@angular/core'; import { FormsModule, ReactiveFormsModule, @@ -11,9 +17,16 @@ import { ComponentDocsViewerComponent } from '../../components/component-docs-vi import { CodeExampleComponent } from '../../components/code-example/code-example.component'; import ComponentData from '../../api-data/cps-autocomplete.json'; import { autocompleteExamples } from './autocomplete-page.examples'; -import { Observable, Subject, of, delay } from 'rxjs'; -import { switchMap, tap } from 'rxjs/operators'; +import { Observable, Subject, Subscription, of, delay } from 'rxjs'; +import { catchError, switchMap, tap } from 'rxjs/operators'; import { CommonModule } from '@angular/common'; +import { + CpsLoggerService, + CpsScenario, + CpsScenarioTelemetryService, + traceScenario +} from 'cps-telemetry'; +import '../../services/telemetry.schema'; @Component({ imports: [ @@ -30,7 +43,7 @@ import { CommonModule } from '@angular/common'; changeDetection: ChangeDetectionStrategy.Eager, host: { class: 'composition-page' } }) -export class AutocompletePageComponent implements OnInit { +export class AutocompletePageComponent implements OnInit, OnDestroy { options = [ { name: 'New York', data: { code: 'NY' }, alias: 'NYC' }, { @@ -89,6 +102,42 @@ export class AutocompletePageComponent implements OnInit { validating = false; selectedOption: any = null; + private readonly scenarioTelemetry = inject(CpsScenarioTelemetryService); + private readonly logger = inject(CpsLoggerService).getLogger('autocomplete'); + + /** The in-flight search per autocomplete, so a newer query can cancel it. */ + private singleSearchScenario?: CpsScenario; + private multiSearchScenario?: CpsScenario; + /** The in-flight selection validation, so a newer one can cancel it. */ + private validateScenario?: CpsScenario; + + private readonly _validateOptionSubject$ = new Subject<any>(); + private _validateSubscription?: Subscription; + + /** Per-side accessors for the two identically-shaped autocompletes. */ + private readonly _searchSides: Record< + 'single' | 'multi', + { + operation: 'single' | 'multi'; + getScenario: () => CpsScenario | undefined; + setScenario: (scenario: CpsScenario | undefined) => void; + setLoading: (loading: boolean) => void; + } + > = { + single: { + operation: 'single', + getScenario: () => this.singleSearchScenario, + setScenario: (scenario) => (this.singleSearchScenario = scenario), + setLoading: (loading) => (this.isSingleLoading = loading) + }, + multi: { + operation: 'multi', + getScenario: () => this.multiSearchScenario, + setScenario: (scenario) => (this.multiSearchScenario = scenario), + setLoading: (loading) => (this.isMultiLoading = loading) + } + }; + get availableOptionInfo() { return this.options.map((option) => option.name).join(', '); } @@ -104,13 +153,22 @@ export class AutocompletePageComponent implements OnInit { this.singleOptionsObservable$ = this._defineOptionsObservable( this._singleFilterOptionSubject$, - true + 'single' ); this.multiOptionsObservable$ = this._defineOptionsObservable( this._multiFilterOptionSubject$, - false + 'multi' ); + + this._validateSubscription = this._defineValidateOptionObservable(); + } + + ngOnDestroy(): void { + this.singleSearchScenario?.cancel({ reason: 'component-destroyed' }); + this.multiSearchScenario?.cancel({ reason: 'component-destroyed' }); + this.validateScenario?.cancel({ reason: 'component-destroyed' }); + this._validateSubscription?.unsubscribe(); } onSingleInputChanged(val: string) { @@ -128,24 +186,62 @@ export class AutocompletePageComponent implements OnInit { private _defineOptionsObservable( subject$: Subject<string>, - single: boolean + side: 'single' | 'multi' ): Observable<any> | undefined { + const state = this._searchSides[side]; + return subject$.pipe( switchMap((value) => { - if (single) this.isSingleLoading = true; - else this.isMultiLoading = true; + state.getScenario()?.cancel({ reason: 'superseded' }); + + const scenario = this.scenarioTelemetry.start({ + name: 'autocomplete-search', + feature: 'autocomplete', + operation: state.operation + }); + scenario.step('query'); + state.setScenario(scenario); + state.setLoading(true); + + let resultCount = 0; return this._getOptionsFromServer(value).pipe( tap({ + next: (results: unknown[]) => { + resultCount = results.length; + }, complete: () => { - if (single) this.isSingleLoading = false; - else this.isMultiLoading = false; + scenario.complete({ metadata: { resultCount } }); + this._clearSearchState(side); } + }), + catchError((error: unknown) => { + this.logger.error('Autocomplete search failed', { + error, + context: 'Autocomplete', + correlationId: scenario.id + }); + scenario.fail({ error }); + this._clearSearchState(side); + return of([]); }) ); }) ); } + /** Resets the loading flag and forgets the finished scenario for one side. */ + private _clearSearchState(side: 'single' | 'multi'): void { + const state = this._searchSides[side]; + state.setLoading(false); + state.setScenario(undefined); + } + + /** The `validate` counterpart of {@link _clearSearchState}. */ + private _clearValidateState(): void { + this.validating = false; + this.validateScenario = undefined; + } + private _getOptionsFromServer(val: string): Observable<any> { const filteredRes = this.options.filter((option) => { return option.name?.toLowerCase()?.includes(val); @@ -153,25 +249,51 @@ export class AutocompletePageComponent implements OnInit { return of(filteredRes).pipe(delay(1000)); } + /** Simulates async validation of a selected option with a delay. */ + private _validateOption(option: any): Observable<any> { + return of(option).pipe(delay(3000)); + } + // Method to handle selection changes for async validation onOptionSelected(option: any) { this.validating = true; this.selectedOption = option; this.externalError = ''; - // Simulate async validation with a delay - of(option) + this._validateOptionSubject$.next(option); + } + + /** Routes selections through `switchMap` so a newer one cancels a running validation. */ + private _defineValidateOptionObservable(): Subscription { + return this._validateOptionSubject$ .pipe( - delay(3000) // Simulate a delay of 2 seconds + switchMap((option) => { + const scenario = this.scenarioTelemetry.start({ + name: 'autocomplete-validate', + feature: 'autocomplete' + }); + scenario.step('validate'); + this.validateScenario = scenario; + + return this._validateOption(option).pipe( + traceScenario(scenario, { + cancelOutcome: { reason: 'superseded' } + }), + tap(() => this._clearValidateState()), + catchError((error: unknown) => { + // Handle errors and finalize validation state + this.externalError = 'Validation failed'; + this.logger.error('Autocomplete selection failed validation', { + error, + context: 'Autocomplete', + correlationId: scenario.id + }); + this._clearValidateState(); + return of(undefined); + }) + ); + }) ) - .subscribe({ - next: () => { - this.validating = false; - }, - error: () => { - // Handle errors and finalize validation state - this.externalError = 'Validation failed'; - } - }); + .subscribe(); } readonly examples = autocompleteExamples; diff --git a/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.html b/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.html index f0bce5632..9eec1da03 100644 --- a/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.html +++ b/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.html @@ -37,9 +37,9 @@ ariaLabel="Upload pictures or PDFs" width="31.25rem" fileNameTooltipOffset="0.9375rem" - [fileProcessingCallback]="processUploadedFile" + [fileProcessingCallback]="processExtraInfoUploadedFile" (fileProcessingFailed)="onFileProcessingFailed($event)" - (fileProcessingCancelled)="onFileProcessingCancelled($event)" + (fileProcessingCancelled)="onExtraInfoFileProcessingCancelled($event)" (fileUploadFailed)="onFileUploadFailed($event)" (fileUploaded)="onFileUploaded($event)" (fileProcessed)="onFileProcessed($event)" @@ -58,6 +58,7 @@ width="31.25rem" [fileProcessingCallback]="processFailingUploadedFile" (fileProcessingFailed)="onFileProcessingFailed($event)" + (fileProcessingCancelled)="onFailingFileProcessingCancelled()" (fileUploaded)="onFileUploaded($event)"> </cps-file-upload> </app-code-example> @@ -75,9 +76,9 @@ width="31.25rem" fileNameTooltipOffset="0.9375rem" [disabled]="isDisabled" - [fileProcessingCallback]="processUploadedFile" + [fileProcessingCallback]="processDisabledUploadedFile" (fileProcessingFailed)="onFileProcessingFailed($event)" - (fileProcessingCancelled)="onFileProcessingCancelled($event)" + (fileProcessingCancelled)="onDisabledFileProcessingCancelled($event)" (fileUploadFailed)="onFileUploadFailed($event)" (fileUploaded)="onFileUploaded($event)" (fileProcessed)="onFileProcessed($event)" diff --git a/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.spec.ts b/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.spec.ts new file mode 100644 index 000000000..979ad803f --- /dev/null +++ b/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.spec.ts @@ -0,0 +1,175 @@ +import { TestBed } from '@angular/core/testing'; +import { take } from 'rxjs'; +import { + CPS_LOG_API_PROVIDER, + CpsNoopTelemetrySink, + CpsScenarioTelemetryService, + CpsTelemetrySink, + provideCpsTelemetry +} from 'cps-telemetry'; +import { AppLogApiProvider } from '../../services/app-log-api.provider'; +import { FileUploadPageComponent } from './file-upload-page.component'; + +/** jsdom's `File` has no `.text()`, so this stands in with just what's used. */ +function makeFile(name: string): File { + return { + name, + text: () => Promise.resolve('contents') + } as unknown as File; +} + +describe('FileUploadPageComponent', () => { + let component: FileUploadPageComponent; + let scenarioTelemetry: CpsScenarioTelemetryService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'composition-test', + environment: 'test', + version: '0.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useClass: CpsNoopTelemetrySink } + ] + }).compileComponents(); + + component = TestBed.runInInjectionContext( + () => new FileUploadPageComponent() + ); + scenarioTelemetry = TestBed.inject(CpsScenarioTelemetryService); + }); + + describe('processing scenarios within one widget', () => { + it("should track each filename's scenario independently", () => { + component.processExtraInfoUploadedFile(makeFile('a.txt')).subscribe(); + component.processExtraInfoUploadedFile(makeFile('b.txt')).subscribe(); + + expect(scenarioTelemetry.getActive()).toHaveLength(2); + }); + + it('should cancel only the file that fired the cancel, not a different one still in flight', () => { + component.processExtraInfoUploadedFile(makeFile('a.txt')).subscribe(); + component.processExtraInfoUploadedFile(makeFile('b.txt')).subscribe(); + + const settled = jest.fn(); + scenarioTelemetry.settled$.subscribe(settled); + + component.onExtraInfoFileProcessingCancelled('a.txt'); + + expect(settled).toHaveBeenCalledTimes(1); + expect(settled).toHaveBeenCalledWith( + expect.objectContaining({ status: 'abandoned' }) + ); + expect(scenarioTelemetry.getActive()).toHaveLength(1); + }); + + it('should not leave either scenario open once both are cancelled', () => { + component.processExtraInfoUploadedFile(makeFile('a.txt')).subscribe(); + component.processExtraInfoUploadedFile(makeFile('b.txt')).subscribe(); + + component.onExtraInfoFileProcessingCancelled('a.txt'); + component.onExtraInfoFileProcessingCancelled('b.txt'); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should tolerate a cancel for a filename with nothing in flight', () => { + expect(() => + component.onExtraInfoFileProcessingCancelled('never-started.txt') + ).not.toThrow(); + }); + }); + + describe('processing scenarios across the two widgets', () => { + it('should track both scenarios independently even when the two widgets upload a same-named file', () => { + component + .processExtraInfoUploadedFile(makeFile('report.csv')) + .subscribe(); + component.processDisabledUploadedFile(makeFile('report.csv')).subscribe(); + + expect(scenarioTelemetry.getActive()).toHaveLength(2); + }); + + it("should cancel only the widget that fired the cancel, not the other widget's same-named file", () => { + component + .processExtraInfoUploadedFile(makeFile('report.csv')) + .subscribe(); + component.processDisabledUploadedFile(makeFile('report.csv')).subscribe(); + + component.onExtraInfoFileProcessingCancelled('report.csv'); + + expect(scenarioTelemetry.getActive()).toHaveLength(1); + + component.onDisabledFileProcessingCancelled('report.csv'); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + }); + + describe('successful processing', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('should settle the scenario as success, even wrapped in take(1) the way CpsFileUploadComponent consumes it', async () => { + const settled = jest.fn(); + scenarioTelemetry.settled$.subscribe(settled); + + component + .processExtraInfoUploadedFile(makeFile('a.txt')) + .pipe(take(1)) + .subscribe(); + + await jest.advanceTimersByTimeAsync(3000); + + expect(settled).toHaveBeenCalledWith( + expect.objectContaining({ status: 'success' }) + ); + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + }); + + describe('onFailingFileProcessingCancelled', () => { + it('should cancel the failing-widget scenario, not leave it open until its timeout', () => { + component.processFailingUploadedFile().subscribe(); + expect(scenarioTelemetry.getActive()).toHaveLength(1); + + component.onFailingFileProcessingCancelled(); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should settle it as abandoned, not let it self-settle as a timeout', () => { + const settled = jest.fn(); + scenarioTelemetry.settled$.subscribe(settled); + + component.processFailingUploadedFile().subscribe(); + component.onFailingFileProcessingCancelled(); + + expect(settled).toHaveBeenCalledWith( + expect.objectContaining({ status: 'abandoned' }) + ); + }); + + it('should tolerate a cancel with nothing in flight', () => { + expect(() => component.onFailingFileProcessingCancelled()).not.toThrow(); + }); + }); + + describe('ngOnDestroy', () => { + it('should cancel every widget still processing when the page is destroyed', () => { + component.processExtraInfoUploadedFile(makeFile('a.txt')).subscribe(); + component.processDisabledUploadedFile(makeFile('b.txt')).subscribe(); + component.processFailingUploadedFile().subscribe(); + + component.ngOnDestroy(); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should not throw when nothing is in flight', () => { + expect(() => component.ngOnDestroy()).not.toThrow(); + }); + }); +}); diff --git a/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.ts b/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.ts index ddfed7ee5..4895fd406 100644 --- a/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.ts +++ b/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.ts @@ -1,4 +1,10 @@ -import { Component, ViewChild, ChangeDetectionStrategy } from '@angular/core'; +import { + Component, + OnDestroy, + ViewChild, + ChangeDetectionStrategy, + inject +} from '@angular/core'; import { CpsFileUploadComponent, CpsButtonToggleComponent, @@ -6,6 +12,12 @@ import { CpsButtonComponent } from 'cps-ui-kit'; import { Observable, catchError, delay, from, map, of } from 'rxjs'; +import { + CpsLoggerService, + CpsScenario, + CpsScenarioTelemetryService +} from 'cps-telemetry'; +import '../../services/telemetry.schema'; import ComponentData from '../../api-data/cps-file-upload.json'; import { ComponentDocsViewerComponent } from '../../components/component-docs-viewer/component-docs-viewer.component'; @@ -26,9 +38,25 @@ import { fileUploadExamples } from './file-upload-page.examples'; changeDetection: ChangeDetectionStrategy.Eager, host: { class: 'composition-page' } }) -export class FileUploadPageComponent { +export class FileUploadPageComponent implements OnDestroy { @ViewChild('fileUpload') fileUpload?: CpsFileUploadComponent; + private readonly scenarioTelemetry = inject(CpsScenarioTelemetryService); + private readonly logger = inject(CpsLoggerService).getLogger('file-upload'); + + /** Processing scenarios in flight for the "extra info" widget, keyed by filename. */ + private extraInfoUploadScenarios = new Map<string, CpsScenario>(); + + /** Same as {@link extraInfoUploadScenarios}, for the "disabled" widget. */ + private disabledUploadScenarios = new Map<string, CpsScenario>(); + + /** + * The processing scenario for the failing-upload demo widget, which has no + * `fileProcessingCancelled` wiring of its own and whose callback receives + * no `File` to key a map entry by. + */ + private failingUploadScenario?: CpsScenario; + componentData = ComponentData; readonly examples = fileUploadExamples; @@ -45,22 +73,63 @@ export class FileUploadPageComponent { fileInfo: string = 'The file should be a small sample file to infer the schema, which will be shown in the next step'; - processUploadedFile(file: File): Observable<boolean> { + processExtraInfoUploadedFile = (file: File): Observable<boolean> => + this._processUploadedFile(file, this.extraInfoUploadScenarios); + + processDisabledUploadedFile = (file: File): Observable<boolean> => + this._processUploadedFile(file, this.disabledUploadScenarios); + + private _processUploadedFile( + file: File, + scenarios: Map<string, CpsScenario> + ): Observable<boolean> { + const scenario = this._startUploadScenario('process'); + scenarios.set(file.name, scenario); + return from(file.text()).pipe( delay(3000), map((fileContentsAsText) => { console.log(fileContentsAsText); + scenario.complete({ metadata: { fileSize: file.size } }); + scenarios.delete(file.name); return true; }), catchError((error) => { - console.error('Error reading file', error); + scenario.fail({ error }); + this.logger.error('Error reading file', { + error, + context: 'FileUpload', + correlationId: scenario.id + }); + scenarios.delete(file.name); return of(false); }) ); } - processFailingUploadedFile(): Observable<boolean> { - return of(false).pipe(delay(500)); + processFailingUploadedFile = (): Observable<boolean> => { + const scenario = this._startUploadScenario('process-failing'); + this.failingUploadScenario = scenario; + + return of(false).pipe( + delay(500), + map((result) => { + scenario.fail(); + this.failingUploadScenario = undefined; + return result; + }) + ); + }; + + /** Starts a `file-upload` scenario and opens its `process` step. */ + private _startUploadScenario(operation: string): CpsScenario { + const scenario = this.scenarioTelemetry.start({ + name: 'file-upload', + feature: 'file-upload', + operation + }); + scenario.step('process'); + return scenario; } onFileUploaded(file: File) { @@ -79,8 +148,41 @@ export class FileUploadPageComponent { console.log('File processing failed', fileName); } - onFileProcessingCancelled(fileName: string) { + onExtraInfoFileProcessingCancelled(fileName: string) { console.log('File processing cancelled', fileName); + this._cancel(this.extraInfoUploadScenarios, fileName, 'user-cancelled'); + } + + onDisabledFileProcessingCancelled(fileName: string) { + console.log('File processing cancelled', fileName); + this._cancel(this.disabledUploadScenarios, fileName, 'user-cancelled'); + } + + onFailingFileProcessingCancelled(): void { + this.failingUploadScenario?.cancel({ reason: 'user-cancelled' }); + this.failingUploadScenario = undefined; + } + + private _cancel( + scenarios: Map<string, CpsScenario>, + fileName: string, + reason: string + ): void { + scenarios.get(fileName)?.cancel({ reason }); + scenarios.delete(fileName); + } + + ngOnDestroy(): void { + this._cancelAll(this.extraInfoUploadScenarios); + this._cancelAll(this.disabledUploadScenarios); + this.failingUploadScenario?.cancel({ reason: 'component-destroyed' }); + } + + private _cancelAll(scenarios: Map<string, CpsScenario>): void { + for (const scenario of scenarios.values()) { + scenario.cancel({ reason: 'component-destroyed' }); + } + scenarios.clear(); } onUploadedFileRemoved(fileName: string) { diff --git a/projects/composition/src/app/pages/table-page/table-page.component.spec.ts b/projects/composition/src/app/pages/table-page/table-page.component.spec.ts new file mode 100644 index 000000000..312c7fbd9 --- /dev/null +++ b/projects/composition/src/app/pages/table-page/table-page.component.spec.ts @@ -0,0 +1,122 @@ +import { TestBed } from '@angular/core/testing'; +import { + CPS_LOG_API_PROVIDER, + CpsNoopTelemetrySink, + CpsScenarioTelemetryService, + CpsTelemetrySink, + provideCpsTelemetry +} from 'cps-telemetry'; +import { AppLogApiProvider } from '../../services/app-log-api.provider'; +import { TablePageComponent } from './table-page.component'; + +describe('TablePageComponent', () => { + let component: TablePageComponent; + let scenarioTelemetry: CpsScenarioTelemetryService; + + beforeEach(async () => { + jest.useFakeTimers(); + + await TestBed.configureTestingModule({ + imports: [TablePageComponent], + providers: [ + provideCpsTelemetry({ + application: 'composition-test', + environment: 'test', + version: '0.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useClass: CpsNoopTelemetrySink } + ] + }).compileComponents(); + + component = TestBed.runInInjectionContext(() => new TablePageComponent()); + scenarioTelemetry = TestBed.inject(CpsScenarioTelemetryService); + }); + + afterEach(() => jest.useRealTimers()); + + describe('ngOnDestroy', () => { + it('should cancel a lazy-load scenario still in flight, not leave it open', () => { + component.onLazyLoad({ first: 0, rows: 10 }); + expect(scenarioTelemetry.getActive()).toHaveLength(1); + + component.ngOnDestroy(); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should settle it as abandoned, not leave it to auto-settle as a misleading timeout', () => { + const settled = jest.fn(); + scenarioTelemetry.settled$.subscribe(settled); + + component.onLazyLoad({ first: 0, rows: 10 }); + component.ngOnDestroy(); + + expect(settled).toHaveBeenCalledWith( + expect.objectContaining({ status: 'abandoned' }) + ); + }); + + it('should not throw when there is no lazy load in flight', () => { + expect(() => component.ngOnDestroy()).not.toThrow(); + }); + + it('should stop a pending lazy-load timer from completing a destroyed scenario', async () => { + component.onLazyLoad({ first: 0, rows: 10 }); + await Promise.resolve(); + + component.ngOnDestroy(); + jest.advanceTimersByTime(600); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + }); + + it('should not populate lazy data via a timer created after a same-tick destroy', async () => { + component.dataVirtual = Array.from({ length: 5 }, (_, i) => ({ + a: `a${i}`, + b: `b${i % 5}`, + c: i, + d: new Date(), + e: i % 2 === 0, + f: new Date() + })); + + component.onLazyLoad({ first: 0, rows: 10 }); + component.ngOnDestroy(); + + await Promise.resolve(); + jest.advanceTimersByTime(600); + + expect(component.lazyData).toEqual([]); + expect(component.lazyLoading).toBe(false); + }); + }); + + describe('onLazyLoad', () => { + it('should aggregate one format-row call per fetched row', async () => { + component.dataVirtual = Array.from({ length: 10 }, (_, i) => ({ + a: `a${i}`, + b: `b${i % 5}`, + c: i, + d: new Date(), + e: i % 2 === 0, + f: new Date() + })); + + const settled = jest.fn(); + scenarioTelemetry.settled$.subscribe(settled); + + component.onLazyLoad({ first: 0, rows: 10 }); + await Promise.resolve(); + jest.advanceTimersByTime(600); + + expect(settled).toHaveBeenCalledWith( + expect.objectContaining({ + aggregates: [ + expect.objectContaining({ name: 'format-row', callCount: 10 }) + ] + }) + ); + }); + }); +}); diff --git a/projects/composition/src/app/pages/table-page/table-page.component.ts b/projects/composition/src/app/pages/table-page/table-page.component.ts index c2f84a5a7..315e3d50d 100644 --- a/projects/composition/src/app/pages/table-page/table-page.component.ts +++ b/projects/composition/src/app/pages/table-page/table-page.component.ts @@ -30,6 +30,8 @@ import { tableExamples } from './table-page.examples'; import ComponentData from '../../api-data/cps-table.json'; import { DatePipe, PercentPipe, UpperCasePipe } from '@angular/common'; +import { CpsScenario, CpsScenarioTelemetryService } from 'cps-telemetry'; +import '../../services/telemetry.schema'; @Component({ selector: 'app-table-page', @@ -415,6 +417,10 @@ export class TablePageComponent implements OnInit, OnDestroy { private _lazyLoadTimeout?: ReturnType<typeof setTimeout>; private _lastLazyFirst = -1; private _lastLazyRows = -1; + private readonly _scenarioTelemetry = inject(CpsScenarioTelemetryService); + private _lazyLoadScenario?: CpsScenario; + /** Checked in the queued callbacks below, past `ngOnDestroy`'s clearTimeout. */ + private _destroyed = false; manyRecordsForPaginator = false; @@ -443,7 +449,9 @@ export class TablePageComponent implements OnInit, OnDestroy { } ngOnDestroy(): void { + this._destroyed = true; clearTimeout(this._lazyLoadTimeout); + this._lazyLoadScenario?.cancel({ reason: 'component-destroyed' }); } private _genVirtualData() { @@ -506,13 +514,36 @@ export class TablePageComponent implements OnInit, OnDestroy { this._lastLazyFirst = first; this._lastLazyRows = rows; + this._lazyLoadScenario?.cancel({ reason: 'superseded' }); + const scenario = this._scenarioTelemetry.start({ + name: 'table-page-load', + feature: 'table' + }); + scenario.step('fetch'); + this._lazyLoadScenario = scenario; + Promise.resolve().then(() => { + if (this._destroyed) return; this.lazyLoading = true; clearTimeout(this._lazyLoadTimeout); this._lazyLoadTimeout = setTimeout(() => { + if (this._destroyed) return; this.lazyTotalRecords = this.dataVirtual.length; - this.lazyData = this.dataVirtual.slice(first, first + rows); + + this.lazyData = this.dataVirtual + .slice(first, first + rows) + .map((row) => { + scenario.aggregateStart('format-row'); + const formatted = { ...row }; + scenario.aggregateEnd('format-row'); + return formatted; + }); + this.lazyLoading = false; + scenario.complete({ + metadata: { first, rows, totalRecords: this.lazyTotalRecords } + }); + this._lazyLoadScenario = undefined; }, 600); }); } diff --git a/projects/composition/src/app/services/app-log-api.provider.spec.ts b/projects/composition/src/app/services/app-log-api.provider.spec.ts new file mode 100644 index 000000000..1cc9035db --- /dev/null +++ b/projects/composition/src/app/services/app-log-api.provider.spec.ts @@ -0,0 +1,194 @@ +import { TestBed } from '@angular/core/testing'; +import { CpsLogRecord } from 'cps-telemetry'; +import { AppLogApiProvider } from './app-log-api.provider'; + +/** Builds a minimal, valid CpsLogRecord, overridable per test. */ +function record(overrides: Partial<CpsLogRecord> = {}): CpsLogRecord { + return { + timestamp: '2024-01-01T00:00:00.000Z', + level: 'log', + message: 'hello', + application: 'composition', + environment: 'test', + version: '1.0.0', + ...overrides + }; +} + +describe('AppLogApiProvider', () => { + let provider: AppLogApiProvider; + + beforeEach(() => { + TestBed.configureTestingModule({}); + provider = TestBed.inject(AppLogApiProvider); + }); + + describe('send / getRecords', () => { + it('should retain sent records, oldest first', () => { + provider.send(record({ message: 'first' })); + provider.send(record({ message: 'second' })); + + expect(provider.getRecords().map((r) => r.message)).toEqual([ + 'first', + 'second' + ]); + }); + + it('should return a copy, not the live buffer', () => { + provider.send(record()); + const records = provider.getRecords(); + records.push(record({ message: 'injected' })); + + expect(provider.getRecords()).toHaveLength(1); + }); + + it('should evict the oldest records once the buffer limit is exceeded', () => { + for (let i = 0; i < 505; i++) { + provider.send(record({ message: `msg-${i}` })); + } + + const records = provider.getRecords(); + expect(records).toHaveLength(500); + expect(records[0].message).toBe('msg-5'); + expect(records[records.length - 1].message).toBe('msg-504'); + }); + }); + + describe('query', () => { + beforeEach(() => { + provider.send( + record({ + correlationId: 'c-1', + logger: 'file-upload', + level: 'log', + timestamp: '2024-01-01T00:00:00.000Z' + }) + ); + provider.send( + record({ + correlationId: 'c-2', + logger: 'file-upload', + level: 'warn', + timestamp: '2024-01-02T00:00:00.000Z' + }) + ); + provider.send( + record({ + correlationId: 'c-1', + logger: 'autocomplete', + level: 'error', + timestamp: '2024-01-03T00:00:00.000Z' + }) + ); + }); + + it('should filter by correlationId', async () => { + const found = await provider.query({ correlationId: 'c-1' }); + expect(found.map((r) => r.logger)).toEqual([ + 'file-upload', + 'autocomplete' + ]); + }); + + it('should filter by logger', async () => { + const found = await provider.query({ logger: 'autocomplete' }); + expect(found).toHaveLength(1); + expect(found[0].correlationId).toBe('c-1'); + }); + + it('should filter by minLevel, keeping that level and above', async () => { + const found = await provider.query({ minLevel: 'warn' }); + expect(found.map((r) => r.level)).toEqual(['warn', 'error']); + }); + + it('should filter by an inclusive from/to timestamp range', async () => { + const found = await provider.query({ + from: '2024-01-02T00:00:00.000Z', + to: '2024-01-02T00:00:00.000Z' + }); + expect(found).toHaveLength(1); + expect(found[0].correlationId).toBe('c-2'); + }); + + it('should compare a non-UTC-offset from bound by its actual instant, not by lexicographic string order', async () => { + const found = await provider.query({ from: '2024-01-02T01:00:00+02:00' }); + expect(found.map((r) => r.correlationId)).toEqual(['c-2', 'c-1']); + }); + + it('should cap the result at limit', async () => { + const found = await provider.query({ limit: 2 }); + expect(found).toHaveLength(2); + }); + + it('should combine filters with AND', async () => { + const found = await provider.query({ + correlationId: 'c-1', + logger: 'file-upload' + }); + expect(found).toHaveLength(1); + expect(found[0].logger).toBe('file-upload'); + }); + + it('should return everything when the filter is empty', async () => { + const found = await provider.query({}); + expect(found).toHaveLength(3); + }); + }); + + describe('download', () => { + // jsdom has no URL.createObjectURL/revokeObjectURL, so these are assigned + // directly rather than jest.spyOn, which needs the property to exist. + let originalCreateObjectURL: typeof URL.createObjectURL; + let originalRevokeObjectURL: typeof URL.revokeObjectURL; + + beforeEach(() => { + originalCreateObjectURL = URL.createObjectURL; + originalRevokeObjectURL = URL.revokeObjectURL; + }); + + afterEach(() => { + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + jest.restoreAllMocks(); + }); + + it('should do nothing when URL.createObjectURL is unavailable', () => { + // @ts-expect-error removing it to exercise the guard + delete URL.createObjectURL; + + provider.send(record()); + expect(() => provider.download()).not.toThrow(); + }); + + it('should build and click a download link, then revoke the object URL', () => { + const objectUrl = 'blob:mock-url'; + URL.createObjectURL = jest.fn(() => objectUrl); + URL.revokeObjectURL = jest.fn(); + const clickSpy = jest + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => undefined); + + provider.send(record({ message: 'downloadable' })); + provider.download('my-logs.json'); + + expect(clickSpy).toHaveBeenCalled(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith(objectUrl); + expect(document.querySelector('a[download]')).toBeNull(); + }); + + it('should revoke the object URL even if the click throws', () => { + URL.createObjectURL = jest.fn(() => 'blob:mock-url'); + URL.revokeObjectURL = jest.fn(); + jest + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => { + throw new Error('blocked by browser'); + }); + + provider.send(record()); + expect(() => provider.download()).toThrow('blocked by browser'); + expect(URL.revokeObjectURL).toHaveBeenCalled(); + expect(document.querySelector('a[download]')).toBeNull(); + }); + }); +}); diff --git a/projects/composition/src/app/services/app-log-api.provider.ts b/projects/composition/src/app/services/app-log-api.provider.ts new file mode 100644 index 000000000..206b07a1c --- /dev/null +++ b/projects/composition/src/app/services/app-log-api.provider.ts @@ -0,0 +1,107 @@ +import { Injectable } from '@angular/core'; +import { CPS_LOG_LEVEL_ORDER } from 'cps-telemetry'; +import type { + CpsLogApiProvider, + CpsLogQuery, + CpsLogRecord +} from 'cps-telemetry'; + +/** Records retained before the oldest are dropped. */ +const BUFFER_LIMIT = 500; + +/** + * This application's in-memory log store. + * + * The telemetry library expects a backend to receive log records; this is + * that backend for the documentation site. Records are held in a bounded + * ring buffer and go nowhere — a real application replaces the body of + * {@link send} with a call to its own log-ingestion endpoint. + * + * Read them from DevTools with `logger.query({ correlationId })`, or set + * `localStorage.debugLogger = 'true'` to watch them arrive. + */ +@Injectable({ providedIn: 'root' }) +export class AppLogApiProvider implements CpsLogApiProvider { + private readonly buffer: CpsLogRecord[] = []; + + /** @inheritdoc */ + send(record: CpsLogRecord): void { + this.buffer.push(record); + if (this.buffer.length > BUFFER_LIMIT) { + this.buffer.splice(0, this.buffer.length - BUFFER_LIMIT); + } + } + + /** @inheritdoc */ + query(filter: CpsLogQuery): Promise<CpsLogRecord[]> { + let found = [...this.buffer]; + + if (filter.correlationId) { + found = found.filter((r) => r.correlationId === filter.correlationId); + } + if (filter.logger) { + found = found.filter((r) => r.logger === filter.logger); + } + if (filter.minLevel) { + const floor = CPS_LOG_LEVEL_ORDER[filter.minLevel]; + found = found.filter((r) => CPS_LOG_LEVEL_ORDER[r.level] >= floor); + } + if (filter.from) { + const fromTime = new Date(filter.from).getTime(); + found = found.filter((r) => new Date(r.timestamp).getTime() >= fromTime); + } + if (filter.to) { + const toTime = new Date(filter.to).getTime(); + found = found.filter((r) => new Date(r.timestamp).getTime() <= toTime); + } + if (filter.limit !== undefined) { + found = found.slice(0, filter.limit); + } + + return Promise.resolve(found); + } + + /** + * The retained records, oldest first. Synchronous, for inspection from + * DevTools and tests. + * + * @returns a copy of the buffer + */ + getRecords(): CpsLogRecord[] { + return [...this.buffer]; + } + + /** + * Saves the retained records to a file for download. + * + * @param filename the file to save as + */ + download(filename = 'cps-logs.json'): void { + if ( + typeof document === 'undefined' || + !document.body || + typeof Blob !== 'function' || + typeof URL.createObjectURL !== 'function' + ) { + return; + } + + const url = URL.createObjectURL( + new Blob([JSON.stringify(this.buffer, undefined, 2)], { + type: 'application/json' + }) + ); + + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + + try { + anchor.click(); + } finally { + anchor.remove(); + URL.revokeObjectURL(url); + } + } +} diff --git a/projects/composition/src/app/services/app-telemetry.service.spec.ts b/projects/composition/src/app/services/app-telemetry.service.spec.ts new file mode 100644 index 000000000..4aee935c1 --- /dev/null +++ b/projects/composition/src/app/services/app-telemetry.service.spec.ts @@ -0,0 +1,572 @@ +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + NavigationCancel, + NavigationCancellationCode, + NavigationEnd, + NavigationError, + NavigationSkipped, + NavigationStart, + Router +} from '@angular/router'; +import { Subject } from 'rxjs'; +import { + CPS_LOG_API_PROVIDER, + CpsLogRecord, + CpsScenarioTelemetryService, + CpsTelemetrySink, + provideCpsTelemetry, + withScenarios +} from 'cps-telemetry'; +import { AppLogApiProvider } from '../services/app-log-api.provider'; +import { AppTelemetryService } from './app-telemetry.service'; + +interface RecordedEvent { + eventType: string; + payload: Record<string, unknown>; +} + +describe('AppTelemetryService', () => { + let service: AppTelemetryService; + let routerEvents: Subject<unknown>; + let events: RecordedEvent[]; + + beforeEach(() => { + routerEvents = new Subject<unknown>(); + events = []; + + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { + application: 'composition-test', + environment: 'test', + version: '0.0.0' + }, + withScenarios({ defaultTimeoutMs: 0 }) + ), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { + provide: CpsTelemetrySink, + useValue: { + record: (eventType: string, payload: Record<string, unknown>) => + events.push({ eventType, payload }), + recordError: () => undefined, + getSessionId: () => 'session-1', + setUserId: () => undefined, + getUserId: () => undefined, + flush: () => undefined + } + }, + { provide: Router, useValue: { events: routerEvents.asObservable() } } + ] + }); + + service = TestBed.inject(AppTelemetryService); + service.start(); + }); + + /** The scenario records emitted so far. */ + function scenarios(): Record<string, unknown>[] { + return events + .filter((e) => e.eventType === 'com.cps.scenario') + .map((e) => e.payload); + } + + it('should not emit anything before a navigation starts', () => { + expect(events).toHaveLength(0); + }); + + it('should open a scenario on NavigationStart', () => { + routerEvents.next(new NavigationStart(1, '/button')); + + const scenarioTelemetry = TestBed.inject(CpsScenarioTelemetryService); + expect(scenarioTelemetry.getActive()).toHaveLength(1); + expect(scenarioTelemetry.getActive()[0].name).toBe('route-navigation'); + expect(events).toHaveLength(0); + }); + + it('should complete the scenario on NavigationEnd', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next(new NavigationEnd(1, '/button', '/button')); + + const [record] = scenarios(); + expect(record).toMatchObject({ + scenarioName: 'route-navigation', + operation: 'lazy-route-load', + route: '/button', + status: 'success', + metadata: { finalUrl: '/button' } + }); + expect(record.steps).toHaveLength(4); + }); + + it('should strip a query string and fragment from the route before recording it', () => { + routerEvents.next(new NavigationStart(1, '/colors?experimental=true#hash')); + routerEvents.next( + new NavigationEnd(1, '/colors?experimental=true#hash', '/colors') + ); + + expect(scenarios()[0]).toMatchObject({ route: '/colors' }); + }); + + it('should strip matrix parameters from every segment, not just the query string and fragment', () => { + routerEvents.next( + new NavigationStart(1, '/customers;id=123/details;tab=billing?x=1#h') + ); + routerEvents.next( + new NavigationEnd( + 1, + '/customers;id=123/details;tab=billing?x=1#h', + '/customers/details' + ) + ); + + expect(scenarios()[0]).toMatchObject({ route: '/customers/details' }); + }); + + it('should cancel the scenario on NavigationCancel', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next( + new NavigationCancel( + 1, + '/button', + 'guard rejected', + NavigationCancellationCode.GuardRejected + ) + ); + + expect(scenarios()[0]).toMatchObject({ + status: 'abandoned', + reason: 'guard-rejected', + message: 'guard rejected' + }); + }); + + it('should still name a cause when the router supplies no cancellation code', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next(new NavigationCancel(1, '/button', '')); + + expect(scenarios()[0]).toMatchObject({ + status: 'abandoned', + reason: 'navigation-cancelled' + }); + expect(scenarios()[0].message).toBeUndefined(); + }); + + describe('NavigationSkipped', () => { + it('should clear a stale intent so it cannot backdate an unrelated navigation', () => { + jest.useFakeTimers(); + + service.markNavigationIntent(); + routerEvents.next(new NavigationSkipped(1, '/button', 'same url')); + + jest.advanceTimersByTime(500); + routerEvents.next(new NavigationStart(2, '/select')); + routerEvents.next(new NavigationEnd(2, '/select', '/select')); + + expect(scenarios()[0].delta).toBeLessThan(100); + + jest.useRealTimers(); + }); + + it('should settle a scenario NavigationStart already opened before the skip follows', () => { + routerEvents.next(new NavigationStart(1, '/button')); + + const scenarioTelemetry = TestBed.inject(CpsScenarioTelemetryService); + expect(scenarioTelemetry.getActive()).toHaveLength(1); + + routerEvents.next(new NavigationSkipped(1, '/button', 'same url')); + + expect(scenarioTelemetry.getActive()).toHaveLength(0); + expect(scenarios()[0]).toMatchObject({ status: 'abandoned' }); + }); + + it('should ignore a skip for a navigation that never started', () => { + routerEvents.next(new NavigationSkipped(99, '/never-started', 'x')); + expect(events).toHaveLength(0); + }); + + it('should settle a scenario stashed by a redirect-to-current-URL, instead of leaving it for a later navigation to wrongly reuse', () => { + routerEvents.next(new NavigationStart(1, '/checkbox')); + routerEvents.next( + new NavigationCancel( + 1, + '/checkbox', + 'Redirecting to "/checkbox/examples"', + NavigationCancellationCode.Redirect + ) + ); + routerEvents.next( + new NavigationSkipped(2, '/checkbox/examples', 'same url') + ); + + const scenarioTelemetry = TestBed.inject(CpsScenarioTelemetryService); + expect(scenarioTelemetry.getActive()).toHaveLength(0); + expect(scenarios()[0]).toMatchObject({ + route: '/checkbox', + status: 'abandoned' + }); + + routerEvents.next(new NavigationStart(3, '/select')); + routerEvents.next(new NavigationEnd(3, '/select', '/select')); + + expect(scenarios()).toHaveLength(2); + expect(scenarios()[1]).toMatchObject({ + route: '/select', + status: 'success' + }); + }); + }); + + it('should fail the scenario on NavigationError', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next( + new NavigationError(1, '/button', new Error('chunk load failed')) + ); + + const record = scenarios()[0]; + expect(record.status).toBe('failure'); + expect(record.error).toMatchObject({ message: 'chunk load failed' }); + }); + + describe('logging', () => { + /** Log records captured by the default in-memory transport. */ + function logs(): CpsLogRecord[] { + return TestBed.inject(AppLogApiProvider).getRecords(); + } + + it('should log once when tracking starts', () => { + const started = logs().filter((r) => r.message === 'Application started'); + expect(started).toHaveLength(1); + expect(started[0]).toMatchObject({ + level: 'log', + context: 'AppTelemetry' + }); + }); + + it('should not log again when start is called twice', () => { + service.start(); + expect( + logs().filter((r) => r.message === 'Application started') + ).toHaveLength(1); + }); + + it('should log a navigation failure', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next( + new NavigationError(1, '/button', new Error('chunk load failed')) + ); + + const failure = logs().find((r) => r.message === 'Navigation failed'); + expect(failure).toMatchObject({ + level: 'error', + context: 'route-navigation', + error: { message: 'chunk load failed' }, + metadata: { url: '/button' } + }); + }); + + it('should correlate the log line with the scenario record', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next( + new NavigationError(1, '/button', new Error('chunk load failed')) + ); + + const failure = logs().find((r) => r.message === 'Navigation failed'); + expect(failure?.correlationId).toBe(scenarios()[0].scenarioId); + }); + + it('should not log on a successful navigation', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next(new NavigationEnd(1, '/button', '/button')); + + expect(logs().some((r) => r.level === 'error')).toBe(false); + }); + + it('should not log on a cancelled navigation', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next(new NavigationCancel(1, '/button', 'superseded')); + + expect(logs().some((r) => r.level === 'error')).toBe(false); + }); + }); + + it('should keep concurrent navigations independent', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next(new NavigationStart(2, '/select')); + + const scenarioTelemetry = TestBed.inject(CpsScenarioTelemetryService); + expect(scenarioTelemetry.getActive()).toHaveLength(2); + + routerEvents.next( + new NavigationCancel( + 1, + '/button', + 'superseded', + NavigationCancellationCode.SupersededByNewNavigation + ) + ); + routerEvents.next(new NavigationEnd(2, '/select', '/select')); + + const records = scenarios(); + expect(records).toHaveLength(2); + expect(records[0]).toMatchObject({ + route: '/button', + status: 'abandoned', + reason: 'superseded' + }); + expect(records[1]).toMatchObject({ route: '/select', status: 'success' }); + expect(records[0].scenarioId).not.toBe(records[1].scenarioId); + }); + + describe('redirect-caused navigation restarts', () => { + it('should continue the same scenario across a guard-redirect restart, instead of opening a second one', () => { + routerEvents.next(new NavigationStart(1, '/checkbox')); + routerEvents.next( + new NavigationCancel( + 1, + '/checkbox', + 'redirect', + NavigationCancellationCode.Redirect + ) + ); + routerEvents.next(new NavigationStart(2, '/checkbox/examples')); + routerEvents.next( + new NavigationEnd(2, '/checkbox/examples', '/checkbox/examples') + ); + + const records = scenarios(); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ + status: 'success', + route: '/checkbox', + metadata: { finalUrl: '/checkbox/examples' } + }); + }); + + it('should still cancel the scenario when the cancellation is not a redirect', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next( + new NavigationCancel( + 1, + '/button', + 'guard rejected', + NavigationCancellationCode.GuardRejected + ) + ); + routerEvents.next(new NavigationStart(2, '/select')); + routerEvents.next(new NavigationEnd(2, '/select', '/select')); + + const records = scenarios(); + expect(records).toHaveLength(2); + expect(records[0]).toMatchObject({ + status: 'abandoned', + route: '/button' + }); + expect(records[1]).toMatchObject({ status: 'success', route: '/select' }); + }); + }); + + describe('navigation intent', () => { + it('should backdate the scenario to the recorded click', () => { + const nowSpy = jest.spyOn(Date, 'now'); + const clickedAt = Date.now(); + + nowSpy.mockReturnValue(clickedAt); + service.markNavigationIntent(); + + nowSpy.mockReturnValue(clickedAt + 300); + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next(new NavigationEnd(1, '/button', '/button')); + + const startTimeMs = new Date( + scenarios()[0].startTime as string + ).getTime(); + expect(startTimeMs).toBeLessThanOrEqual(clickedAt + 300); + }); + + it('should ignore a stale intent from an unrelated click', () => { + const nowSpy = jest.spyOn(Date, 'now'); + const clickedAt = Date.now(); + + nowSpy.mockReturnValue(clickedAt); + service.markNavigationIntent(); + + nowSpy.mockReturnValue(clickedAt + 10_000); + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next(new NavigationEnd(1, '/button', '/button')); + + expect(scenarios()[0].delta).toBeLessThan(1_000); + }); + + it('should consume the intent so it cannot backdate a second navigation', () => { + service.markNavigationIntent(); + + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next(new NavigationEnd(1, '/button', '/button')); + routerEvents.next(new NavigationStart(2, '/select')); + routerEvents.next(new NavigationEnd(2, '/select', '/select')); + + expect(scenarios()).toHaveLength(2); + expect(scenarios()[1].delta).toBeLessThan(1_000); + }); + + it('should work with no intent recorded at all', () => { + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next(new NavigationEnd(1, '/button', '/button')); + + expect(scenarios()[0].status).toBe('success'); + }); + }); + + it('should ignore a settle event for an unknown navigation', () => { + routerEvents.next(new NavigationEnd(99, '/never-started', '/x')); + expect(events).toHaveLength(0); + }); + + it('should not double-subscribe when started twice', () => { + service.start(); + routerEvents.next(new NavigationStart(1, '/button')); + routerEvents.next(new NavigationEnd(1, '/button', '/button')); + + expect(scenarios()).toHaveLength(1); + }); + + it('should track a theme change as a BI event', () => { + service.trackThemeChanged('dark'); + + const bi = events.filter((e) => e.eventType === 'com.cps.bi'); + expect(bi).toHaveLength(1); + expect(bi[0].payload).toMatchObject({ + eventName: 'theme_changed', + metadata: { theme: 'dark' } + }); + }); + + describe('trackClick', () => { + /** The BI events emitted so far. */ + function biEvents(): Record<string, unknown>[] { + return events + .filter((e) => e.eventType === 'com.cps.bi') + .map((e) => e.payload); + } + + it('should use the action as the BI event name', () => { + service.trackClick('code_copied'); + + expect(biEvents()).toHaveLength(1); + expect(biEvents()[0]).toMatchObject({ eventName: 'code_copied' }); + }); + + it('should forward metadata describing the interaction', () => { + service.trackClick('sidebar_searched', { + queryLength: 4, + resultCount: 2, + hasResults: true + }); + + expect(biEvents()[0]).toMatchObject({ + eventName: 'sidebar_searched', + metadata: { queryLength: 4, resultCount: 2, hasResults: true } + }); + }); + + it('should work without metadata', () => { + service.trackClick('sidebar_toggled'); + expect(biEvents()[0].metadata).toBeUndefined(); + }); + + it('should collapse a double-fired click', () => { + service.trackClick('code_copied'); + service.trackClick('code_copied'); + + expect(biEvents()).toHaveLength(1); + }); + + it('should not throw when the telemetry sink is broken', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'composition-test', + environment: 'test', + version: '0.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { + provide: CpsTelemetrySink, + useValue: { + record: () => { + throw new Error('sink is broken'); + }, + recordError: () => undefined, + getSessionId: () => undefined, + setUserId: () => undefined, + getUserId: () => undefined, + flush: () => undefined + } + }, + { provide: Router, useValue: { events: routerEvents.asObservable() } } + ] + }); + + const isolated = TestBed.inject(AppTelemetryService); + expect(() => isolated.trackClick('code_copied')).not.toThrow(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('failed'), + expect.any(Error) + ); + + consoleError.mockRestore(); + }); + }); + + describe('server-side rendering', () => { + it('should not throw, and should omit navigator-derived metadata, when running on the server', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { + application: 'composition-test', + environment: 'test', + version: '0.0.0' + }, + withScenarios({ defaultTimeoutMs: 0 }) + ), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { + provide: CpsTelemetrySink, + useValue: { + record: (eventType: string, payload: Record<string, unknown>) => + events.push({ eventType, payload }), + recordError: () => undefined, + getSessionId: () => 'session-1', + setUserId: () => undefined, + getUserId: () => undefined, + flush: () => undefined + } + }, + { + provide: Router, + useValue: { events: routerEvents.asObservable() } + }, + { provide: PLATFORM_ID, useValue: 'server' } + ] + }); + + const serverService = TestBed.inject(AppTelemetryService); + expect(() => serverService.start()).not.toThrow(); + + const logs = TestBed.inject(AppLogApiProvider).getRecords(); + const started = logs.find((r) => r.message === 'Application started'); + expect(started).toBeDefined(); + expect(started?.metadata).toBeUndefined(); + }); + }); +}); diff --git a/projects/composition/src/app/services/app-telemetry.service.ts b/projects/composition/src/app/services/app-telemetry.service.ts new file mode 100644 index 000000000..aed451d18 --- /dev/null +++ b/projects/composition/src/app/services/app-telemetry.service.ts @@ -0,0 +1,278 @@ +import { DestroyRef, inject, Injectable, PLATFORM_ID } from '@angular/core'; +import { isPlatformBrowser } from '@angular/common'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + Event as RouterEvent, + NavigationCancel, + NavigationCancellationCode, + NavigationEnd, + NavigationError, + NavigationSkipped, + NavigationSkippedCode, + NavigationStart, + Router +} from '@angular/router'; +import { + CpsBiEventName, + CpsBiTelemetryService, + CpsLoggerService, + CpsScenario, + CpsScenarioTelemetryService, + CpsTelemetryMetadata +} from 'cps-telemetry'; +// Side-effect import for the module augmentation declaring the scenario, step +// and business event vocabulary used below. +import './telemetry.schema'; + +/** Name shared by every route-navigation scenario. */ +const NAVIGATION_SCENARIO = 'route-navigation'; + +/** Beyond this, a recorded click is assumed not to have caused the navigation. */ +const INTENT_MAX_AGE_MS = 2_000; + +const CANCEL_REASON: Partial<Record<NavigationCancellationCode, string>> = { + [NavigationCancellationCode.Redirect]: 'redirect', + [NavigationCancellationCode.SupersededByNewNavigation]: 'superseded', + [NavigationCancellationCode.NoDataFromResolver]: 'no-data-from-resolver', + [NavigationCancellationCode.GuardRejected]: 'guard-rejected', + [NavigationCancellationCode.Aborted]: 'aborted' +}; + +/** As {@link CANCEL_REASON}, for a navigation the router never ran. */ +const SKIP_REASON: Partial<Record<NavigationSkippedCode, string>> = { + [NavigationSkippedCode.IgnoredSameUrlNavigation]: 'same-url', + [NavigationSkippedCode.IgnoredByUrlHandlingStrategy]: 'url-handling-strategy' +}; + +/** + * Names the cause behind a router code. + */ +function causeOf<TCode extends number>( + causes: Partial<Record<TCode, string>>, + code: TCode | undefined, + fallback: string +): string { + return (code === undefined ? undefined : causes[code]) ?? fallback; +} + +/** + * Wires the documentation app's own telemetry: tracks route navigations as + * scenarios and forwards BI and log events. + * + * Started from `AppComponent` so it is created once the application is running. + */ +@Injectable({ providedIn: 'root' }) +export class AppTelemetryService { + private readonly router = inject(Router); + private readonly scenarioTelemetry = inject(CpsScenarioTelemetryService); + private readonly biTelemetry = inject(CpsBiTelemetryService); + private readonly logger = inject(CpsLoggerService).getLogger('app'); + private readonly destroyRef = inject(DestroyRef); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + + /** + * Angular identifies concurrent navigations by id, so scenarios are keyed the + * same way. A single "current navigation" field would attribute the wrong + * duration whenever one navigation supersedes another. + */ + private readonly navigations = new Map<number, CpsScenario>(); + + private pendingRedirectScenario?: CpsScenario; + + /** When the user last did something that should start a navigation. */ + private navigationIntentAt?: number; + + private started = false; + + /** Begins tracking router navigations. Safe to call more than once. */ + start(): void { + if (this.started) { + return; + } + this.started = true; + + // Marks the start of a session in the log stream. Application, environment + // and version are stamped onto every record by the logger, so this line + // exists to bound the session rather than to carry them. + this.logger.log('Application started', { + context: 'AppTelemetry', + metadata: this.isBrowser ? { language: navigator.language } : undefined + }); + + this.router.events + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => this.onRouterEvent(event)); + } + + /** + * Records that the user just did something expected to start a navigation. + * + * The router raises `NavigationStart` a little after the click that caused it — + * event handling, guards and change detection all run first. Measuring from + * here rather than from the router event keeps that gap, which the user + * experienced as part of the wait, inside the recorded duration. + */ + markNavigationIntent(): void { + this.navigationIntentAt = Date.now(); + } + + /** + * Records a business event for a theme change. + * + * @param theme the theme the user switched to + */ + trackThemeChanged(theme: string): void { + this.biTelemetry.track('theme_changed', { theme }); + } + + /** + * Records a meaningful user interaction. + * + * Repeat clicks within a short window are collapsed by the telemetry layer, + * so no throttling is needed here. + * + * @param action the interaction name, e.g. `code_copied`, as declared in + * `telemetry.schema.ts` + * @param metadata attributes describing the interaction — ids, route names + * and tab names only, never emails, usernames or account numbers + */ + trackClick(action: CpsBiEventName, metadata?: CpsTelemetryMetadata): void { + this.biTelemetry.track(action, metadata); + } + + private onRouterEvent(event: RouterEvent): void { + if (event instanceof NavigationStart) { + const scenario = + this.consumePendingRedirectScenario() ?? + this.scenarioTelemetry.start({ + name: NAVIGATION_SCENARIO, + operation: 'lazy-route-load', + route: this.routeTemplate(event.url), + // The journey began when the user clicked, which is a little + // before this handler runs. Backdating to the router's own event + // timestamp keeps that gap inside the measurement. + startedAt: this.navigationStartedAt(), + // A navigation that has neither completed nor been superseded + // within this window is a stuck chunk load, not a slow one. + timeoutMs: 30_000 + }); + scenario.step('resolve-route'); + this.navigations.set(event.id, scenario); + return; + } + + if (event instanceof NavigationEnd) { + this.settle(event.id, (scenario) => { + scenario.step('activate'); + scenario.complete({ + metadata: { finalUrl: event.urlAfterRedirects } + }); + }); + return; + } + + if (event instanceof NavigationCancel) { + const scenario = this.navigations.get(event.id); + if (scenario && event.code === NavigationCancellationCode.Redirect) { + this.navigations.delete(event.id); + this.pendingRedirectScenario = scenario; + return; + } + + this.settle(event.id, (scenario) => + scenario.cancel({ + reason: causeOf(CANCEL_REASON, event.code, 'navigation-cancelled'), + message: event.reason || undefined + }) + ); + return; + } + + if (event instanceof NavigationSkipped) { + this.navigationIntentAt = undefined; + const reason = causeOf(SKIP_REASON, event.code, 'navigation-skipped'); + this.consumePendingRedirectScenario()?.cancel({ + reason, + message: event.reason || undefined + }); + this.settle(event.id, (scenario) => + scenario.cancel({ reason, message: event.reason || undefined }) + ); + return; + } + + if (event instanceof NavigationError) { + this.settle(event.id, (scenario) => { + // The scenario id doubles as the correlation id, which is what lets + // this line and the scenario record be pulled together afterwards. + this.logger.error('Navigation failed', { + error: event.error, + context: NAVIGATION_SCENARIO, + correlationId: scenario.id, + metadata: { url: event.url } + }); + scenario.fail({ error: event.error }); + }); + } + } + + /** + * Reduces a router event's URL to a route template. + * + * `event.url` is the *resolved* URL — exactly what `cps-telemetry`'s own + * doc comment on `route` warns against passing directly: it wants a + * template (`/customers/:id`), not `/customers/john@example.com`, so that + * `route` stays one metric dimension per route rather than splitting into + * one series per resolved value. This app's own routes are all static (see + * `app-routing.module.ts` — no `:id`-style segments anywhere), so a + * resolved URL and its template only ever differ by the query string, + * fragment, and any matrix parameters (`;key=value`) a link happened to + * carry; stripping those is enough here. Matrix parameters are legal on + * any segment of a static route too, not just parameterized ones, so they + * are stripped per-segment rather than assumed absent. + */ + private routeTemplate(url: string): string { + return url + .split(/[?#]/)[0] + .split('/') + .map((segment) => segment.split(';')[0]) + .join('/'); + } + + /** + * Consumes the pending navigation-intent timestamp, if one is fresh enough. + * + * A stale mark — the user clicked a link, then navigated by some other route + * entirely — would backdate an unrelated journey, so anything older than the + * window is discarded. + * + * @returns the click timestamp, or `undefined` to measure from now + */ + private navigationStartedAt(): number | undefined { + const at = this.navigationIntentAt; + this.navigationIntentAt = undefined; + + if (at === undefined || Date.now() - at > INTENT_MAX_AGE_MS) { + return undefined; + } + return at; + } + + private consumePendingRedirectScenario(): CpsScenario | undefined { + const scenario = this.pendingRedirectScenario; + this.pendingRedirectScenario = undefined; + return scenario; + } + + private settle( + navigationId: number, + apply: (scenario: CpsScenario) => void + ): void { + const scenario = this.navigations.get(navigationId); + if (!scenario) { + return; + } + this.navigations.delete(navigationId); + apply(scenario); + } +} diff --git a/projects/composition/src/app/services/rum-credentials.provider.spec.ts b/projects/composition/src/app/services/rum-credentials.provider.spec.ts new file mode 100644 index 000000000..4d2bd3127 --- /dev/null +++ b/projects/composition/src/app/services/rum-credentials.provider.spec.ts @@ -0,0 +1,112 @@ +import { TestBed } from '@angular/core/testing'; +import { AppRumCredentialsProvider } from './rum-credentials.provider'; + +/** A well-formed `/rum/init` broker response. */ +function initResponse(overrides: Record<string, unknown> = {}) { + return { + enabled: true, + config: { + applicationId: 'app-monitor-1', + region: 'eu-west-1', + sessionSampleRate: 0.5, + applicationVersion: '3.0.0' + }, + credentials: { + accessKeyId: 'AKIA', + secretAccessKey: 'secret', + sessionToken: 'token', + expiration: '2030-01-01T00:00:00.000Z' + }, + ...overrides + }; +} + +function mockFetch(status: number, body: unknown): jest.Mock { + const fetchMock = jest.fn().mockResolvedValue({ + ok: status >= 200 && status < 300, + json: () => Promise.resolve(body) + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; +} + +describe('AppRumCredentialsProvider', () => { + let provider: AppRumCredentialsProvider; + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + TestBed.configureTestingModule({}); + provider = TestBed.inject(AppRumCredentialsProvider); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('should request /rum/init, asking for a JSON response and bypassing the HTTP cache', async () => { + const fetchMock = mockFetch(200, initResponse()); + + await provider.load(); + + expect(fetchMock).toHaveBeenCalledWith('/rum/init', { + headers: { Accept: 'application/json' }, + cache: 'no-store' + }); + }); + + it('should map a well-formed response onto CpsRumBootstrap field-for-field', async () => { + mockFetch(200, initResponse()); + + const bootstrap = await provider.load(); + + expect(bootstrap).toEqual({ + config: { + applicationId: 'app-monitor-1', + region: 'eu-west-1', + applicationVersion: '3.0.0', + sessionSampleRate: 0.5 + }, + credentials: { + accessKeyId: 'AKIA', + secretAccessKey: 'secret', + sessionToken: 'token', + expiration: '2030-01-01T00:00:00.000Z' + } + }); + }); + + it('should return null when the broker responds with a non-ok status', async () => { + mockFetch(500, {}); + + expect(await provider.load()).toBeNull(); + }); + + it('should return null when RUM is switched off for this deployment', async () => { + mockFetch(200, initResponse({ enabled: false })); + + expect(await provider.load()).toBeNull(); + }); + + it('should return null when config is missing even though enabled is true', async () => { + mockFetch(200, initResponse({ config: undefined })); + + expect(await provider.load()).toBeNull(); + }); + + it('should return null when credentials are missing, not start an unauthenticated client', async () => { + mockFetch(200, initResponse({ credentials: undefined })); + + expect(await provider.load()).toBeNull(); + }); + + it('should return null, not reject, when fetch itself fails at the network level', async () => { + globalThis.fetch = jest + .fn() + .mockRejectedValue( + new TypeError('Failed to fetch') + ) as unknown as typeof fetch; + + await expect(provider.load()).resolves.toBeNull(); + }); +}); diff --git a/projects/composition/src/app/services/rum-credentials.provider.ts b/projects/composition/src/app/services/rum-credentials.provider.ts new file mode 100644 index 000000000..9e85f0bba --- /dev/null +++ b/projects/composition/src/app/services/rum-credentials.provider.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@angular/core'; +import type { + CpsRumBootstrap, + CpsRumCredentialsProvider +} from 'cps-telemetry/rum'; + +/** + * Shape returned by the backend RUM broker. + */ +interface RumInitResponse { + enabled: boolean; + config?: { + applicationId: string; + region: string; + sessionSampleRate: number; + applicationVersion: string; + }; + credentials?: { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + expiration: string; + }; +} + +/** + * Supplies CloudWatch RUM settings and credentials from this application's + * backend broker. + * + * The backend vends short-lived credentials, so the browser never holds a + * long-lived AWS identity. + */ +@Injectable({ providedIn: 'root' }) +export class AppRumCredentialsProvider implements CpsRumCredentialsProvider { + /** + * Fetches the current app monitor settings and credentials. + * + * @returns the bootstrap payload, or `null` when RUM is switched off for this + * deployment or the broker is unreachable + */ + async load(): Promise<CpsRumBootstrap | null> { + let response: Response; + try { + response = await fetch('/rum/init', { + headers: { Accept: 'application/json' }, + cache: 'no-store' + }); + } catch { + // A network-level failure (offline, DNS, connection refused, ...) + // rejects before any response exists — treat it the same as an + // unreachable broker, per this method's own documented contract. + return null; + } + + if (!response.ok) { + return null; + } + + const init = (await response.json()) as RumInitResponse; + + // Credentials are required: without them the client would start and then + // fail every dispatch silently. + if (!init?.enabled || !init.config || !init.credentials) { + return null; + } + + return { + config: { + applicationId: init.config.applicationId, + region: init.config.region, + applicationVersion: init.config.applicationVersion, + sessionSampleRate: init.config.sessionSampleRate + }, + credentials: init.credentials + }; + } +} diff --git a/projects/composition/src/app/services/telemetry.schema.ts b/projects/composition/src/app/services/telemetry.schema.ts new file mode 100644 index 000000000..25acfa460 --- /dev/null +++ b/projects/composition/src/app/services/telemetry.schema.ts @@ -0,0 +1,88 @@ +/** + * This application's telemetry vocabulary. + * + * Scenario, step, logger and business event names are metric dimensions, + * declared once here rather than as free text at each call site. Anything not + * listed below is a compile error. + */ +declare module 'cps-telemetry' { + interface CpsScenarioNames { + /** A router navigation, from click to activated route. */ + 'route-navigation': true; + + /** Reading and simulating server-side processing of an uploaded file. */ + 'file-upload': true; + + /** A typeahead query against the demo autocomplete dataset. */ + 'autocomplete-search': true; + + /** Simulated async validation of a selected autocomplete option. */ + 'autocomplete-validate': true; + + /** Simulated server-side page fetch for a lazy-loaded table. */ + 'table-page-load': true; + } + + interface CpsScenarioSteps { + /** Resolving and loading the lazy route chunk. */ + 'resolve-route': true; + + /** Activating the resolved component. */ + activate: true; + + /** Simulated processing of an uploaded file's contents. */ + process: true; + + /** Fetching matching options for a typed query. */ + query: true; + + /** Simulated async validation of a selection. */ + validate: true; + + /** Fetching one page of table rows. */ + fetch: true; + + /** + * Formatting one fetched table row for display. Runs once per row, so + * it's timed as an {@link CpsScenario.aggregateStart}/ + * {@link CpsScenario.aggregateEnd} total rather than as its own step. + */ + 'format-row': true; + } + + interface CpsLoggerNames { + /** Application lifecycle and router navigation. */ + app: true; + + /** Route titles. */ + routing: true; + + /** The documentation site's own components. */ + docs: true; + + /** The file-upload demo page. */ + 'file-upload': true; + + /** The autocomplete demo page. */ + autocomplete: true; + } + + interface CpsBiEventNames { + /** The user switched between light and dark theme. */ + theme_changed: true; + + /** The user changed one theme dimension, e.g. color or radius. */ + theme_option_changed: true; + + /** The user expanded or collapsed the navigation sidebar. */ + sidebar_toggled: true; + + /** The user typed a query into the sidebar's component search. */ + sidebar_searched: true; + + /** The user copied a component's code example. */ + code_copied: true; + } +} + +export {}; diff --git a/projects/composition/src/app/telemetry-trace.capture.spec.ts b/projects/composition/src/app/telemetry-trace.capture.spec.ts new file mode 100644 index 000000000..5fa9cf60b --- /dev/null +++ b/projects/composition/src/app/telemetry-trace.capture.spec.ts @@ -0,0 +1,889 @@ +import { TestBed } from '@angular/core/testing'; +import { FormBuilder } from '@angular/forms'; +import { + NavigationCancel, + NavigationEnd, + NavigationError, + NavigationStart, + Router +} from '@angular/router'; +import * as fs from 'fs'; +import * as path from 'path'; +import { Subject, throwError, timer } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { + CPS_LOG_API_PROVIDER, + CpsLoggerService, + CpsScenarioTelemetryService, + CpsTelemetrySink, + provideCpsTelemetry, + traceScenario, + withLogging, + withRedaction +} from 'cps-telemetry'; +import { AppLogApiProvider } from './services/app-log-api.provider'; +import { AppTelemetryService } from './services/app-telemetry.service'; +import { TablePageComponent } from './pages/table-page/table-page.component'; +import { FileUploadPageComponent } from './pages/file-upload-page/file-upload-page.component'; +import { AutocompletePageComponent } from './pages/autocomplete-page/autocomplete-page.component'; +import './services/telemetry.schema'; + +/** + * Telemetry wire-audit capture harness — not a regression suite, though it + * lives in a `.spec.ts` file and runs under the `composition` Jest project. + * + * Drives `composition`'s real wiring with `debugScenario`/`debugLogger`/ + * `debugBI` on, and records two things per case: the exact payload handed + * to `CpsTelemetrySink`/`AppLogApiProvider`, and the exact console line the + * library's debug flags print. + * + * Every `it()` appends to one shared `trace`/`consoleLines` pair; the final + * test writes both to disk as JSON for `npm run trace:telemetry` to produce. + * + * `real: true` entries come from driving composition's actual pages. + * `real: false` entries call the real library classes directly, for + * statuses/fields composition's demo data doesn't reach on its own. + */ + +interface TraceEntry { + seq: number; + group: string; + case: string; + real: boolean; + source: 'AWS RUM' | 'LOG API'; + eventType: string; + payload: unknown; +} + +interface ConsoleLine { + seq: number; + channel: 'log' | 'warn' | 'error'; + text: string; + payload: unknown; +} + +const trace: TraceEntry[] = []; +const consoleLines: ConsoleLine[] = []; +let seq = 0; + +function pushEntry( + group: string, + caseName: string, + real: boolean, + source: 'AWS RUM' | 'LOG API', + eventType: string, + payload: unknown +): void { + trace.push({ + seq: ++seq, + group, + case: caseName, + real, + source, + eventType, + payload: clone(payload) + }); +} + +/** Deep-clones via JSON round-trip so later mutation of a live object can't retroactively change an already-captured entry. */ +function clone<T>(value: T): T { + if (value === undefined) return value; + return JSON.parse(JSON.stringify(value)); +} + +/** Captures what a `RecordingSink`-style double is handed. */ +class CapturingSink extends CpsTelemetrySink { + group = 'unlabeled'; + real = true; + + record(eventType: string, payload: object): void { + pushEntry( + this.group, + this.currentCase, + this.real, + 'AWS RUM', + eventType, + payload + ); + } + + recordError(error: unknown): void { + pushEntry( + this.group, + this.currentCase, + this.real, + 'AWS RUM', + 'sink.recordError', + error + ); + } + + currentCase = ''; + + getSessionId(): string | undefined { + return 'audit-session'; + } + + setUserId(): void {} + + getUserId(): string | undefined { + return undefined; + } + + flush(): void {} +} + +let restoreConsole: () => void; + +beforeAll(() => { + localStorage.setItem('debugScenario', 'true'); + localStorage.setItem('debugLogger', 'true'); + localStorage.setItem('debugBI', 'true'); + + const original = { + log: console.log, + warn: console.warn, + error: console.error + }; + (['log', 'warn', 'error'] as const).forEach((channel) => { + jest.spyOn(console, channel).mockImplementation((...args: unknown[]) => { + consoleLines.push({ + seq: ++seq, + channel, + text: String(args[0]), + payload: clone(args[1]) + }); + }); + }); + restoreConsole = () => { + console.log = original.log; + console.warn = original.warn; + console.error = original.error; + }; +}); + +afterAll(() => { + restoreConsole(); + localStorage.clear(); +}); + +describe('Real: route-navigation', () => { + let service: AppTelemetryService; + let routerEvents: Subject<unknown>; + let sink: CapturingSink; + let logApi: AppLogApiProvider; + let nextId = 1; + + beforeEach(async () => { + jest.useFakeTimers(); + routerEvents = new Subject<unknown>(); + sink = new CapturingSink(); + sink.group = 'route-navigation'; + + await TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'composition', + environment: 'production', + version: '1.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useValue: sink }, + { provide: Router, useValue: { events: routerEvents.asObservable() } } + ] + }).compileComponents(); + + service = TestBed.inject(AppTelemetryService); + logApi = TestBed.inject(AppLogApiProvider); + service.start(); + }); + + afterEach(() => jest.useRealTimers()); + + it('logs "Application started" at boot', () => { + sink.currentCase = 'app start'; + const started = logApi + .getRecords() + .find((r) => r.message === 'Application started'); + pushEntry( + 'route-navigation', + 'app start log line', + true, + 'LOG API', + 'log', + started + ); + expect(started).toBeDefined(); + }); + + it('NavigationStart -> NavigationEnd settles as success', () => { + sink.currentCase = 'success'; + const id = nextId++; + routerEvents.next(new NavigationStart(id, '/table', undefined, undefined)); + jest.advanceTimersByTime(180); + routerEvents.next(new NavigationEnd(id, '/table', '/table')); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('NavigationStart -> NavigationCancel settles as abandoned', () => { + sink.currentCase = 'abandoned (cancel)'; + const id = nextId++; + routerEvents.next(new NavigationStart(id, '/file-upload')); + jest.advanceTimersByTime(60); + routerEvents.next( + new NavigationCancel(id, '/file-upload', 'guard rejected') + ); + const entry = trace.find( + (e) => e.group === sink.group && e.case === sink.currentCase + ); + expect( + (entry?.payload as { metadata?: { abandonedBy?: string } })?.metadata + ?.abandonedBy + ).toBe('caller'); + }); + + it('a second NavigationStart supersedes the first (abandoned)', () => { + sink.currentCase = 'superseded'; + const first = nextId++; + const second = nextId++; + routerEvents.next(new NavigationStart(first, '/autocomplete')); + jest.advanceTimersByTime(50); + routerEvents.next( + new NavigationCancel(first, '/autocomplete', 'superseded by navigation') + ); + routerEvents.next(new NavigationStart(second, '/autocomplete')); + jest.advanceTimersByTime(160); + routerEvents.next( + new NavigationEnd(second, '/autocomplete', '/autocomplete') + ); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('NavigationStart -> NavigationError settles as failure, correlated to a log line', () => { + sink.currentCase = 'failure'; + const id = nextId++; + routerEvents.next(new NavigationStart(id, '/broken-chunk')); + jest.advanceTimersByTime(220); + routerEvents.next( + new NavigationError( + id, + '/broken-chunk', + 'ChunkLoadError: Loading chunk 12 failed.' + ) + ); + const failLog = logApi + .getRecords() + .find((r) => r.message === 'Navigation failed'); + pushEntry( + 'route-navigation', + 'failure -> correlated log line', + true, + 'LOG API', + 'error', + failLog + ); + expect(failLog?.correlationId).toBeDefined(); + }); + + it('never settling times out after 30s', () => { + sink.currentCase = 'timeout'; + const id = nextId++; + routerEvents.next(new NavigationStart(id, '/stuck-chunk')); + jest.advanceTimersByTime(30_000); + const entry = trace.find( + (e) => e.group === sink.group && e.case === sink.currentCase + ); + expect((entry?.payload as { status?: string })?.status).toBe('timeout'); + }); +}); + +describe('Real: table-page-load', () => { + let component: TablePageComponent; + let sink: CapturingSink; + + function seedRows(n: number) { + component.dataVirtual = Array.from({ length: n }, (_, i) => ({ + a: `a${i}`, + b: `b${i % 5}`, + c: i, + d: new Date(), + e: i % 2 === 0, + f: new Date() + })); + } + + beforeEach(async () => { + jest.useFakeTimers(); + sink = new CapturingSink(); + sink.group = 'table-page-load'; + + await TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'composition', + environment: 'production', + version: '1.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useValue: sink } + ] + }).compileComponents(); + + component = TestBed.runInInjectionContext(() => new TablePageComponent()); + seedRows(20); + }); + + afterEach(() => jest.useRealTimers()); + + it('lazy load settles as success, with the format-row aggregate', async () => { + sink.currentCase = 'success + format-row aggregate'; + component.onLazyLoad({ first: 0, rows: 10 }); + await Promise.resolve(); + jest.advanceTimersByTime(600); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('a second lazy load supersedes the first (abandoned)', async () => { + sink.currentCase = 'superseded'; + component.onLazyLoad({ first: 0, rows: 10 }); + await Promise.resolve(); + jest.advanceTimersByTime(250); + component.onLazyLoad({ first: 10, rows: 10 }); + await Promise.resolve(); + jest.advanceTimersByTime(600); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('component destroyed mid-flight settles as abandoned', async () => { + sink.currentCase = 'component-destroyed'; + component.onLazyLoad({ first: 0, rows: 10 }); + await Promise.resolve(); + jest.advanceTimersByTime(300); + component.ngOnDestroy(); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); +}); + +describe('Real: file-upload', () => { + let component: FileUploadPageComponent; + let sink: CapturingSink; + let logApi: AppLogApiProvider; + + function makeFile(name: string, rejects = false): File { + return { + name, + text: () => + rejects + ? Promise.reject(new Error('Read permission denied by the OS')) + : Promise.resolve('contents') + } as unknown as File; + } + + beforeEach(async () => { + jest.useFakeTimers(); + sink = new CapturingSink(); + sink.group = 'file-upload'; + + await TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'composition', + environment: 'production', + version: '1.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useValue: sink } + ] + }).compileComponents(); + + component = TestBed.runInInjectionContext( + () => new FileUploadPageComponent() + ); + logApi = TestBed.inject(AppLogApiProvider); + }); + + afterEach(() => jest.useRealTimers()); + + it('processExtraInfoUploadedFile settles as success', async () => { + sink.currentCase = 'success'; + component.processExtraInfoUploadedFile(makeFile('schema.json')).subscribe(); + await Promise.resolve(); + jest.advanceTimersByTime(3000); + await Promise.resolve(); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('processFailingUploadedFile settles as failure with no error object', async () => { + sink.currentCase = 'failure (no error argument)'; + component.processFailingUploadedFile().subscribe(); + jest.advanceTimersByTime(500); + const entry = trace.find( + (e) => e.group === sink.group && e.case === sink.currentCase + ); + expect(entry).toBeDefined(); + expect( + (entry?.payload as { status?: string; error?: unknown })?.status + ).toBe('failure'); + expect( + (entry?.payload as { status?: string; error?: unknown })?.error + ).toBeUndefined(); + }); + + it('onExtraInfoFileProcessingCancelled settles as abandoned', async () => { + sink.currentCase = 'abandoned (user-cancelled)'; + component.processExtraInfoUploadedFile(makeFile('big.csv')).subscribe(); + await Promise.resolve(); + jest.advanceTimersByTime(1200); + component.onExtraInfoFileProcessingCancelled('big.csv'); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('ngOnDestroy mid-flight settles as abandoned', async () => { + sink.currentCase = 'abandoned (component-destroyed)'; + component + .processExtraInfoUploadedFile(makeFile('mid-flight.csv')) + .subscribe(); + await Promise.resolve(); + jest.advanceTimersByTime(800); + component.ngOnDestroy(); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('a rejecting file read logs "Error reading file"', async () => { + sink.currentCase = 'error reading file -> log line'; + component + .processExtraInfoUploadedFile(makeFile('corrupt.csv', true)) + .subscribe({ error: () => undefined }); + await Promise.resolve(); + await Promise.resolve(); + const errLog = logApi + .getRecords() + .find((r) => r.message === 'Error reading file'); + pushEntry( + 'file-upload', + 'error reading file -> log line', + true, + 'LOG API', + 'error', + errLog + ); + expect(errLog).toBeDefined(); + }); +}); + +describe('Real: autocomplete', () => { + let component: AutocompletePageComponent; + let sink: CapturingSink; + + beforeEach(async () => { + jest.useFakeTimers(); + sink = new CapturingSink(); + sink.group = 'autocomplete'; + + await TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'composition', + environment: 'production', + version: '1.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useValue: sink }, + FormBuilder + ] + }).compileComponents(); + + component = TestBed.runInInjectionContext( + () => new AutocompletePageComponent(TestBed.inject(FormBuilder)) + ); + component.ngOnInit(); + component.singleOptionsObservable$?.subscribe(); + component.multiOptionsObservable$?.subscribe(); + }); + + afterEach(() => jest.useRealTimers()); + + it('a search settles as success', () => { + sink.currentCase = 'search success'; + component.onSingleInputChanged('lon'); + jest.advanceTimersByTime(1000); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('a search settles as failure (backend stubbed down, same pattern the existing spec uses)', () => { + sink.currentCase = 'search failure'; + jest + .spyOn( + component as unknown as { + _getOptionsFromServer: () => ReturnType<typeof throwError>; + }, + '_getOptionsFromServer' + ) + // `delay()` doesn't delay an error — use `timer` + `switchMap` instead. + .mockReturnValue( + timer(1000).pipe( + switchMap(() => throwError(() => new Error('backend down'))) + ) + ); + component.onSingleInputChanged('lon'); + jest.advanceTimersByTime(1000); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('a second keystroke supersedes the first search (abandoned)', () => { + sink.currentCase = 'search superseded'; + component.onSingleInputChanged('lon'); + jest.advanceTimersByTime(220); + component.onSingleInputChanged('par'); + jest.advanceTimersByTime(1000); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('selecting an option settles validation as success', () => { + sink.currentCase = 'validate success'; + component.onOptionSelected(component.options[0]); + jest.advanceTimersByTime(3000); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('destroying the component mid-flight settles every open scenario as abandoned', () => { + sink.currentCase = 'destroyed mid-flight'; + component.onSingleInputChanged('lon'); + component.onMultiInputChanged('lon'); + component.onOptionSelected(component.options[1]); + jest.advanceTimersByTime(400); + component.ngOnDestroy(); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); +}); + +describe('Real: BI events', () => { + let service: AppTelemetryService; + let sink: CapturingSink; + + beforeEach(async () => { + sink = new CapturingSink(); + sink.group = 'BI events'; + + await TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'composition', + environment: 'production', + version: '1.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useValue: sink }, + { provide: Router, useValue: { events: new Subject().asObservable() } } + ] + }).compileComponents(); + + service = TestBed.inject(AppTelemetryService); + }); + + it('trackThemeChanged records theme_changed', () => { + sink.currentCase = 'theme_changed'; + service.trackThemeChanged('dark'); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('trackClick records sidebar_toggled', () => { + sink.currentCase = 'sidebar_toggled'; + service.trackClick('sidebar_toggled', { expanded: true }); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('trackClick records theme_option_changed', () => { + sink.currentCase = 'theme_option_changed'; + service.trackClick('theme_option_changed', { + dimension: 'color', + value: 'blue' + }); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('an identical click within the dedup window is suppressed', () => { + sink.currentCase = 'code_copied (twice, back to back — second suppressed)'; + const countBefore = trace.filter((e) => e.group === 'BI events').length; + service.trackClick('code_copied', { componentName: 'cps-table' }); + service.trackClick('code_copied', { componentName: 'cps-table' }); + const countAfter = trace.filter((e) => e.group === 'BI events').length; + pushEntry( + 'BI events', + 'duplicate call within 400ms dedup window — second call suppressed', + true, + 'AWS RUM', + 'observation', + { firstCallRecorded: countAfter - countBefore === 1 } + ); + expect(countAfter - countBefore).toBe(1); + }); + + it('the same click after the dedup window elapses is recorded again', () => { + sink.currentCase = 'code_copied (twice, 401ms apart — both recorded)'; + jest.useFakeTimers(); + const countBefore = trace.filter((e) => e.group === sink.group).length; + service.trackClick('code_copied', { componentName: 'cps-select' }); + jest.advanceTimersByTime(401); + service.trackClick('code_copied', { componentName: 'cps-select' }); + const countAfter = trace.filter((e) => e.group === sink.group).length; + jest.useRealTimers(); + expect(countAfter - countBefore).toBe(2); + }); +}); + +describe('Synthetic: statuses and fields composition never triggers', () => { + let sink: CapturingSink; + let scenarioTelemetry: CpsScenarioTelemetryService; + + beforeEach(async () => { + sink = new CapturingSink(); + sink.group = 'synthetic'; + sink.real = false; + + await TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'composition', + environment: 'production', + version: '1.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useValue: sink } + ] + }).compileComponents(); + + scenarioTelemetry = TestBed.inject(CpsScenarioTelemetryService); + }); + + it('`incomplete` — no composition page ever calls .incomplete()', () => { + sink.currentCase = 'incomplete status (no real trigger exists)'; + const scenario = scenarioTelemetry.start({ + name: 'table-page-load', + feature: 'table' + }); + scenario.incomplete({ reason: 'no-results' }); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); + + it('`exceededStepsLimit` — no composition scenario ever opens 50+ steps', () => { + sink.currentCase = 'exceededStepsLimit (no real trigger reaches maxSteps)'; + const scenario = scenarioTelemetry.start({ + name: 'table-page-load', + feature: 'table' + }); + for (let i = 0; i < 55; i++) { + scenario.step('fetch'); + } + scenario.complete(); + const entry = trace.find( + (e) => e.group === sink.group && e.case === sink.currentCase + ); + expect( + (entry?.payload as { exceededStepsLimit?: boolean })?.exceededStepsLimit + ).toBe(true); + }); + + it('autocomplete-validate failure — onOptionSelected has no stubbable network seam', () => { + sink.currentCase = + 'autocomplete-validate failure (no stubbable seam on the component)'; + const scenario = scenarioTelemetry.start({ + name: 'autocomplete-validate', + feature: 'autocomplete' + }); + scenario.step('validate'); + throwError(() => new Error('Selected option failed remote validation')) + .pipe(traceScenario(scenario)) + .subscribe({ error: () => undefined }); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); +}); + +describe('Synthetic: mirrorErrorsToRum (composition leaves this off)', () => { + it('an error-level log is also mirrored to AWS RUM when the config is turned on', () => { + const sink = new CapturingSink(); + sink.group = 'synthetic'; + sink.real = false; + sink.currentCase = 'mirrorErrorsToRum: true (composition never sets this)'; + + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { + application: 'composition', + environment: 'production', + version: '1.0.0' + }, + withLogging({ mirrorErrorsToRum: true }) + ), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useValue: sink } + ] + }); + + const logger = TestBed.inject(CpsLoggerService).getLogger('app'); + logger.error('Simulated unexpected failure', { + error: new Error('Simulated unexpected failure') + }); + expect( + trace.some((e) => e.group === sink.group && e.case === sink.currentCase) + ).toBe(true); + }); +}); + +describe('Synthetic: redaction (composition never logs PII-shaped data)', () => { + it('the key denylist and value-pattern scan both redact, when scanValuePatterns is turned on', () => { + const sink = new CapturingSink(); + sink.group = 'synthetic'; + sink.real = false; + sink.currentCase = + 'redaction of a contact-form-shaped payload (composition never logs anything PII-shaped)'; + + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { + application: 'composition', + environment: 'production', + version: '1.0.0' + }, + withRedaction({ scanValuePatterns: ['email', 'creditCard'] }) + ), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useValue: sink } + ] + }); + + const logApi = TestBed.inject(AppLogApiProvider); + const logger = TestBed.inject(CpsLoggerService).getLogger('app'); + logger.log('User submitted contact form', { + metadata: { + email: 'jane.doe@example.com', + password: 'hunter2', + notes: 'Card ending in 4111 1111 1111 1111, please call back.' + } + }); + const record = logApi + .getRecords() + .find((r) => r.message === 'User submitted contact form'); + pushEntry( + 'synthetic', + 'redaction with scanValuePatterns on', + false, + 'LOG API', + 'log', + record + ); + expect(record?.metadata?.password).toBe('[redacted]'); + }); +}); + +describe('Synthetic: minLevel filtering (composition leaves minLevel at the default "log")', () => { + it('a log-level call is dropped once minLevel is raised to "warn"', () => { + const sink = new CapturingSink(); + sink.group = 'synthetic'; + sink.real = false; + sink.currentCase = + 'minLevel: "warn" (composition never overrides minLevel)'; + + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { + application: 'composition', + environment: 'production', + version: '1.0.0' + }, + withLogging({ minLevel: 'warn' }) + ), + { provide: CPS_LOG_API_PROVIDER, useExisting: AppLogApiProvider }, + { provide: CpsTelemetrySink, useValue: sink } + ] + }); + + const logApi = TestBed.inject(AppLogApiProvider); + const logger = TestBed.inject(CpsLoggerService).getLogger('app'); + logger.log('This should be dropped by minLevel'); + logger.warn('This should still go through'); + + const records = logApi.getRecords(); + pushEntry( + 'synthetic', + 'minLevel: "warn" filtering observation', + false, + 'LOG API', + 'observation', + { + droppedCallRecorded: records.some( + (r) => r.message === 'This should be dropped by minLevel' + ), + warnCallRecorded: records.some( + (r) => r.message === 'This should still go through' + ) + } + ); + expect( + records.some((r) => r.message === 'This should be dropped by minLevel') + ).toBe(false); + expect( + records.some((r) => r.message === 'This should still go through') + ).toBe(true); + }); +}); + +describe('Write trace', () => { + it('writes the captured trace + console transcript to disk', () => { + const outDir = path.join(__dirname, '..', '..', 'telemetry-trace'); + fs.mkdirSync(outDir, { recursive: true }); + fs.writeFileSync( + path.join(outDir, 'trace.json'), + JSON.stringify( + { + generatedAt: new Date().toISOString(), + entries: trace, + consoleLines + }, + undefined, + 2 + ) + ); + expect(trace.length).toBeGreaterThan(0); + expect(consoleLines.length).toBeGreaterThan(0); + }); +}); diff --git a/projects/composition/tsconfig.app.json b/projects/composition/tsconfig.app.json index 2d412bbf4..922b756e4 100644 --- a/projects/composition/tsconfig.app.json +++ b/projects/composition/tsconfig.app.json @@ -3,6 +3,8 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "../../out-tsc/app", + "rootDir": "../../", + "skipLibCheck": true, "types": [] }, "files": ["src/main.ts"], diff --git a/projects/composition/tsconfig.json b/projects/composition/tsconfig.json index 7af56b668..fbd0afe94 100644 --- a/projects/composition/tsconfig.json +++ b/projects/composition/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.json", "compilerOptions": { "outDir": "../../out-tsc/app", - "rootDir": "src", + "rootDir": "../../", + "skipLibCheck": true, "types": [] }, "include": ["src/**/*.ts"], diff --git a/projects/cps-telemetry/DESIGN.md b/projects/cps-telemetry/DESIGN.md new file mode 100644 index 000000000..0463f10a3 --- /dev/null +++ b/projects/cps-telemetry/DESIGN.md @@ -0,0 +1,1965 @@ +# cps-telemetry — development design + +A reusable Angular telemetry layer covering three separate concerns — +application logs, scenario health telemetry, and business/UX events — built +on a shared internal abstraction, with an AWS CloudWatch RUM sink. + +--- + +## 1. Goals + +- **Scenario health.** Measure whether a user journey (load customer data, + open a report, submit a search, export data) succeeded, how long it took, + and where the time went — so a regression shows up as a metric, not as a + support ticket. +- **Correlated diagnostics.** When a journey fails, make it possible to pull + the frontend logs, the frontend telemetry, and the backend logs for _that + one run_, using a single identifier. +- **Code-level diagnostics.** A scenario's status tells you a journey broke; + logs tell you where in the code and why. Both carry the same + `correlationId`, so together they read as one trail — from "what failed" + to "what the code was doing when it did." +- **Product signal.** Record feature adoption and interaction events without + putting business vocabulary inside the telemetry infrastructure itself. +- **Developer debugging.** Let any developer see exactly what telemetry is + being produced, in any environment, just by setting a LocalStorage flag — + no rebuild, no config change, no production switch. +- **Reusability.** Ship as a library another Angular application can install + and configure, with no trace of the application it was extracted from + inside it. +- **Safety.** Telemetry can never break the application, and it never + quietly leaks sensitive data. + +## 2. Non-goals + +Explicitly out of scope: + +- Backend telemetry or log-ingestion APIs. +- CloudWatch infrastructure, dashboards, alarms, or metric definitions. +- Lambda, API Gateway, IAM policies, CDK/Terraform. +- Backend log storage or retention policy. +- Session replay — recording and replaying a user's on-screen session as + video, DOM mutations and all. RUM supports it; we do not enable it. +- A general-purpose observability platform. This is a small library. + +We assume the backend RUM credential broker and the log store already exist: +the library just defines `CpsLogApiProvider`, and the consuming application +implements it. Nothing here mocks either one — a library that ships a fake +backend is shipping a lie about what it actually does. + +--- + +## 3. Proposed architecture + +```mermaid +flowchart TD + A[Angular Application] + + A --> B[CpsLoggerService] + A --> C[CpsScenarioTelemetryService] + A --> D[CpsBiTelemetryService] + + C -->|creates| C2[CpsScenario instance] + + B --> E[CpsLogApiProvider] + + C2 --> G[CpsTelemetrySink] + D --> G + G --> G1[CpsRumTelemetrySink] + G --> G2[CpsNoopTelemetrySink] + + G1 --> H[aws-rum-web] + H --> I[AWS RUM] + I --> J[CloudWatch] + + I --> K[Built-in: page views, web vitals,<br/>JS errors, HTTP, resources, session] + J --> L[Custom: scenario + BI events<br/>→ extended metrics / Logs Insights] + +``` + +### Components + +| Component | Responsibility | +| ----------------------------- | ------------------------------------------------------------------------------------------------ | +| `CpsLoggerService` | Factory for named loggers, one per name, plus reading records back | +| `CpsScenarioTelemetryService` | Creates scenarios; flushes any still-running ones at page unload | +| `CpsScenario` | One independent journey — its steps, aggregates, and outcome | +| `CpsBiTelemetryService` | Discrete business/UX events, deduplicated within a short window | +| `CpsTelemetrySink` | Abstract destination for scenario and BI events | +| `CpsRumTelemetrySink` | The AWS RUM adapter — lazy SDK load, credentials, a pre-init buffer, and flushing | +| `CpsNoopTelemetrySink` | Explicit opt-out — everything runs, nothing ships | +| `CpsLogApiProvider` | The seam where the application supplies its own log store — send, query, and an optional `flush` | +| `CpsRumCredentialsProvider` | The seam where the application supplies its AWS details | + +### Shared infrastructure + +`CpsTelemetryIdentity` + `CPS_TELEMETRY_IDENTITY` (identity, shared by every +concern), `CPS_LOG_CONFIG` / `CPS_SCENARIO_TELEMETRY_CONFIG` / `CPS_BI_TELEMETRY_CONFIG` / +`CPS_REDACT_CONFIG` (one per concern), `CpsTelemetryMetadata`, +`cpsIsDebugEnabled`, `cpsRedactMetadata` / `cpsNormalizeError` / +`cpsScrubString` / `cpsRedactConfigFor`, `cpsSafe` / `cpsSafeVoid` / +`cpsUuid` / `cpsNow`. + +Everything is wired through Angular DI, so the AWS implementation can be +swapped out in production and stubbed in tests. + +### Entry points + +Three: `cps-telemetry`, `cps-telemetry/rum` and `cps-telemetry/diagnostics`. +All barrels list their +exports one by one rather than re-exporting whole modules — this is a +published package, so every exported name is a permanent compatibility +promise. Id generation, the clock, the fail-open wrappers, user timings, +and the broadcast channel plumbing all stay internal, so they can change +freely without a breaking release. Redaction is the exception: an author +writing a custom sink needs `cpsRedactMetadata`, `cpsNormalizeError`, +`cpsScrubString` and `cpsRedactConfigFor`, so those are exported. + +`CpsRumTelemetrySink`, `provideCpsTelemetryRumSink`, and everything +`CpsRumCredentialsProvider`-shaped live in `cps-telemetry/rum`, not the main +entry point. `aws-rum-web` is an optional peer dependency: a bundler +resolves every statically-imported module in a file's graph unconditionally, +and a dynamic `import()`'s specifier at build time regardless of whether +that code path runs — so keeping `CpsRumTelemetrySink` (and its +`await import('aws-rum-web')`) out of the main entry point's module graph is +what keeps `aws-rum-web` resolvable only for code that imports +`cps-telemetry/rum`. An app calling only +`provideCpsTelemetrySink('broadcast' | 'noop')` never needs it. + +`cps-telemetry/diagnostics` — the in-app diagnostics popup (§12) — is split +out for the same reason with a different dependency: it is the only UI in +the package, built from cps-ui-kit components, and cps-ui-kit is an +optional peer dependency (with `@angular/forms` and `@angular/animations`, +which the kit needs). Nothing in the main entry imports it; the built main +bundle and its `.d.ts` contain no reference to cps-ui-kit, and an app that +never imports `cps-telemetry/diagnostics` never needs cps-ui-kit installed. +The popup reads events through `CpsTelemetryMonitor`, which lives in the +main entry because the hand-off sites that publish to it do. + +Building the package resolves cps-ui-kit from its own built output +(`dist/cps-ui-kit`, a `paths` entry in `tsconfig.lib.json`) rather than its +source, so the kit is referenced as the external package it is at runtime +and never compiled into this one. `npm run build:telemetry` therefore builds +cps-ui-kit first. The application and the unit tests still resolve both +libraries from source. + +`cps-telemetry/rum` carries its own private copy of +`cpsSafe`/`cpsSafeVoid`/`cpsIsBrowser`/`cpsIsDevMode`/`cpsUuid`. ng-packagr +fixes every entry point's `rootDir` to its own `src` directory (see its own +`tsconfig.js`), so `cps-telemetry/rum` cannot reach the main entry point's +internal utilities by relative import. `cps-telemetry/diagnostics` keeps +its own copy of the few it needs, for the same reason. + +**Test doubles are not shipped, and do not get their own folder or file.** A +`RecordingSink` or `ThrowingSink` is declared inline, at the top of whichever +spec needs it — duplicated per file rather than shared, since a double used +by one or two consumers does not earn its own abstraction. None of them are +exported. They exist purely to test _this library_; the one browser gap that +still needs a stub is `BroadcastChannel`, which jsdom does not implement at +all, and even that is just a small inline stub in `cps-broadcast.spec.ts` +rather than a dedicated file. An application testing its own code needs a +five-line provider array and, if it wants to assert on emitted events, a +six-method sink double — both are shorter to write than to depend on, and +neither one then constrains this package's API. The build confirms the +separation: none of this appears in the emitted bundle or the `.d.ts`. + +--- + +## 4. Data model + +### Log record + +```ts +interface CpsLogRecord { + timestamp: string; // ISO-8601 + level: 'log' | 'warn' | 'error'; + message: string; // scrubbed, length-capped + logger?: CpsLoggerName; // which part of the app wrote it + context?: string; // free-form subsystem label + metadata?: CpsTelemetryMetadata; + error?: CpsTelemetryError; + correlationId?: string; // usually a scenarioId + application: string; + environment: string; + version: string; + userId?: string; + sessionId?: string; // taken from the sink, so logs join the RUM stream +} +``` + +Only `timestamp`, `level`, `message`, the application identity and the +`logger` name are always present. The _detail_ argument is optional — +`this.logger.log('message')` is a complete, valid call on its own — but the +logger name is not: records are only ever written through a +`getLogger(name)` result, and the name is captured by the logger rather +than read from each call, so `logger` is never absent and never disagrees +with the logger that wrote it. See §10's "Why the name is identity". + +### The name vocabulary + +Scenario names, step names, aggregate names, logger names, and business event +names are all finite types, not plain `string`: + +```ts +// declared by the library, empty +interface CpsScenarioNames {} +interface CpsScenarioSteps {} +interface CpsLoggerNames {} +interface CpsBiEventNames {} + +type CpsScenarioName = keyof CpsScenarioNames extends never + ? string + : keyof CpsScenarioNames; +``` + +Scenario, step, aggregate, and BI event names are metric dimensions. A +misspelled one does not produce a wrong figure — it silently starts a second, +incomplete series, and the alarm built on the first one keeps reading healthy. +An interpolated id causes the exact same problem, just with unlimited possible +values. Both are now compile errors instead. + +A logger name fails differently, but no better: it is the key every record +carries to the backend, so a typo means a whole stream quietly lands in the +wrong place. + +**Why the name is identity.** `CpsLoggerService` is a factory, not a +logger: it exposes `getLogger(name)` and `query()`, nothing that writes. +A name addresses exactly one logger — asking twice returns the same +instance, the way a file name always refers to one file — and that logger +captures its name once, so no call can restate or contradict it. +`CpsLogDetail` accordingly has no `logger` field. + +Four separate capabilities key off `logger`, and a record without one is +reachable by none of them: + +| Capability | Resolves through | +| ----------------------------- | ------------------------------------------------ | +| Per-logger severity floors | `minLevelFor()` → `levels?.[logger] ?? minLevel` | +| Name-scoped console filtering | `cpsIsDebugEnabled('debugLogger', name)` | +| Reading records back by area | `query({ logger })` | +| Attributing a console line | the `[app][logger]` prefix | + +The trap is that none of that is visible where it is lost. An unnamed call +reads identically to a named one, compiles, ships, and only reveals itself +later when turning a noisy area down or filtering the console does nothing. +So the unnamed path is not documented-against — it is absent, and writing +one is a compile error. + +An earlier design let a logger derive further loggers with pre-bound detail +(`child(bindings)`), which is how a scenario used to stamp its correlation +id. It was removed: the one runtime binding it served has no users, a +per-instance binding cannot live in a name-keyed registry anyway, and +"which logger am I actually holding" stopped having a single answer. +Correlation is now passed per call as `correlationId`, which is what +application code was already doing. + +A published package cannot know the names an application will use in +advance, so the registries above start out empty, and the **consuming +application declares its own vocabulary** in a schema file that augments +them: + +```ts +declare module 'cps-telemetry' { + interface CpsScenarioSteps { + 'resolve-route': true; + } +} +``` + +`keyof` an empty interface is `never`, which would make every call +uncallable — hence the fallback to `string`. Adoption is therefore opt-in +and incremental: names are unconstrained until the first augmentation, then +fully checked from then on. Steps and aggregates share one registry because +they name the same kind of thing — a name declared for a step is just as +valid passed to `aggregateStart`. + +The library never writes any log lines of its own, so `CpsLoggerName` is +exactly whatever the application declares — there are no reserved names to +work around. + +### Scenario + +```ts +interface CpsScenarioRecord { + scenarioId: string; // uuid — the correlation id + parentScenarioId?: string; + scenarioName: CpsScenarioName; + feature?: string; + operation?: string; + route?: string; + status?: CpsScenarioStatus; // undefined on a toRecord() snapshot taken mid-flight + statusCode?: string | number; // HTTP status or business code + message?: string; + reason?: string; // structured, low-cardinality — independent of message + error?: CpsTelemetryError; + startTime: string; // ISO-8601 + endTime?: string; // ISO-8601; wall-clock position, not a duration + delta: number; // ms — total duration, the headline latency measure + elapsed: number; // ms since the host page loaded, at settle — a timeline position + stepCount: number; + steps: CpsScenarioStep[]; // forensics in a log query, not metric material (§7) + exceededStepsLimit?: boolean; // steps[] truncated at maxSteps + previousStep?: CpsStepName; // last real step closed before settling + aggregates?: CpsScenarioAggregate[]; + metadata?: CpsTelemetryMetadata; + application: string; + sessionId?: string; + userId?: string; +} + +interface CpsScenarioAggregate { + name: CpsStepName; // shares the step vocabulary + elapsed: number; // summed across every call + callCount: number; +} +``` + +An aggregate is reported separately from steps because it has a duration but +no position on the timeline — it is for work that runs many times inside one +scenario, where the total is the useful number and a hundred individual +steps would just be noise. + +**No `spanId` field.** A 16-hex W3C/X-Ray-format trace span id, separate from +`scenarioId` (the journey correlation id), would only earn its place if it +actually lined a scenario up against a distributed trace — nothing in any +consuming application wires it into an X-Ray header, and nothing else in +this library reads one either. Shipping it anyway would be dead weight on +every event, for a use case nobody exercises. It can be added the day +something actually threads it through. + +**`application`, `sessionId` and `userId` are on this record.** The +alternative — relying on the fact that AWS's own `Dispatch.js` already +attaches `UserDetails: { userId, sessionId }` to _every_ `PutRumEvents` +request, so leaving them off the payload body would save real bytes against +the 200-event session cap — is a real saving, but it loses to a bigger cost: +a payload that makes you cross-reference the outer request envelope just to +answer "who did this, which session, which app" is a worse experience when +you are reading the record directly, whether in a Logs Insights query against +the event body or in any consumer that only ever sees the payload. +Consequently, the extra bytes win. `sessionId` and `userId` stay optional at the type level: +`sessionId` genuinely is not there before the RUM client finishes +initializing, and `userId` only exists once someone has signed in. + +`CpsLogRecord` carries both too, but for a related, distinct reason: logs go +straight to a backend of the application's own choosing, with no RUM +envelope to inherit identity from at all — so the record has to state it +outright. + +### Scenario step + +```ts +interface CpsScenarioStep { + name: CpsStepName | 'scenario-start' | 'scenario-end'; + startOffset: number; // ms from scenario start, not an epoch + endOffset?: number; + stepDelta?: number; // this step's own duration + elapsed?: number; // ms since the host page loaded, at step close — a timeline position + status?: CpsScenarioStepStatus; // same union as the scenario + message?: string; + reason?: string; // structured, low-cardinality — independent of message + error?: CpsTelemetryError; + metadata?: CpsTelemetryMetadata; +} +``` + +Offsets are relative to the scenario's own start, so they stay as small +integers. That matters because every step ships inside the scenario event's +own payload. + +`elapsed`/`stepDelta` split the same way the record's own `elapsed`/`delta` +do, and for the same reason: a duration and a timeline position are +different questions, and should not share one name. `delta` names a duration +on the scenario; `stepDelta` names the same idea on a step. + +**Once a scenario settles, `steps` has at least two entries.** Two +zero-duration markers bookend whatever real steps the caller declared: +`scenario-start`, written in the constructor, and `scenario-end`, written at +settlement, carrying the scenario's own final status plus whichever of +`message`/`metadata`/`error` the settle-time `CpsScenarioOutcome` supplied. +That is the same detail that also closes whatever real step was still open, +and that lands on the record's own root fields. All three views agree with +each other, so reading `steps[]` alone tells the full story without +cross-referencing the root record. A scenario that never calls `.step()` +still gets exactly the two boundary markers once it settles. Neither counts +toward `stepCount`, `exceededStepsLimit` or `maxSteps` — that budget is only +about steps the caller actually opened. The type allows the two literal +names alongside `CpsStepName` rather than requiring every consuming +application to register them, because they are the library's own to write, +not the application's to declare. + +`scenario-end` is only written at settlement, so this two-entry minimum is a +property of a _settled_ record, not a guaranteed one: `toRecord()` is public +and safe to call on a still-running scenario, and a snapshot taken before +the first `.step()` call — before `scenario-end` exists — can have as few as +one entry (just `scenario-start`). + +### BI event + +```ts +interface CpsBiEvent { + eventName: CpsBiEventName; // declared by the application + eventTime: string; + scenarioId?: string; // optional correlation to a journey + feature?: string; + // no `route` — the RUM envelope's pageId already carries the page + metadata?: CpsTelemetryMetadata; + application: string; +} +``` + +`application` is here for the same self-describing-payload reason as the +scenario record. `sessionId`/`userId` were not asked for on BI events, and the +same "AWS already has it" argument applies here unweakened — so they were +left off. + +### Fields deliberately left out + +The brief suggested several fields that are **not** collected. Each has its +own reason: + +| Omitted | Why | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `device`, `browser`, `page` | The RUM envelope already attaches `browserName`, `browserVersion`, `osName`, `osVersion`, `deviceType`, `pageUrl`, `pageId`, `countryCode`, `referrerUrl` to _every_ event. Repeating them would just be duplicated payload against a capped event budget. | +| `sessionId`, `userId` on BI events | Already carried once per session via `addSessionAttributes` for every event type, BI included. The scenario record makes the opposite trade for these two specifically — see above for why. | +| `environment`, `version` on events | Same session-attributes argument. `application` is the one exception here (see above); `environment`/`version` stay off both records. **Except when forwarded across realms**: the host records through _its own_ client, so `CpsBroadcastTelemetrySink` stamps its own `application`/`environment`/`appVersion` onto each forwarded event's _metadata_ — a second, independent place a fragment's identity ends up, since the host has no other way to tell which fragment sent it (§13). A forwarded **error** has no metadata field to carry this in — `AwsRum.recordError(error: any)` takes no second argument at all — so `CpsRumTelemetrySink` instead folds a differing origin into the error's own `name` (e.g. `[fragment-app] TypeError`) rather than silently attributing every fragment error to the shell. | +| `networkType` | The Network Information API is Chromium-only and its values are coarse and unreliable — a metric nobody can trust across browsers is worse than no metric at all. | +| `timeToStart` | RUM's built-in navigation timing already answers "how long until the app was interactive". | +| Separate `latency` | For a scenario that is `delta`; for a step it is the step's own `stepDelta`. A third name for the same number would just invite inconsistent dashboards. | + +--- + +## 5. Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> running: start() + running --> running: step() / setData() / aggregateStart() + running --> SUCCESS: complete() + running --> FAILURE: fail() + running --> ABANDONED: cancel() / pagehide + running --> INCOMPLETE: incomplete() + running --> TIMEOUT: deadline passes + SUCCESS --> [*] + FAILURE --> [*] + ABANDONED --> [*] + INCOMPLETE --> [*] + TIMEOUT --> [*] +``` + +**Valid transitions.** A running scenario can go to exactly one of +`success`, `failure`, `abandoned`, `incomplete`, or `timeout`. Nothing else. + +`timeout` is a deliberate fifth status, split out of `abandoned` — see +below. There is no "in progress" status at all. A running scenario has no +status: `CpsScenario.status` is `undefined` until it settles, and +`isSettled` is just derived from that same field (`status !== undefined`) +rather than tracked as a second, separate boolean that could drift out of +sync with it. + +Nothing is emitted at start either, in any mode: a scenario that has not +settled has no status to report yet, so a start event would have nothing to +say. + +**Why there are four unsuccessful outcomes, not one.** Each demands a +different response, and collapsing them together would make the failure +rate useless for alerting: + +| Status | Meaning | Response | +| ------------ | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `failure` | A defect — the journey broke | Investigate; page someone if the rate spikes | +| `abandoned` | The journey stopped being relevant: navigated away, or the page unloaded | Engagement signal, not a defect | +| `incomplete` | An expected path that did not reach the goal — no results, a guard declined, a flag routed elsewhere | Product signal, not an engineering one | +| `timeout` | The scenario never settled within its deadline | Engagement signal, distinct from a defect and from a deliberate abandon | + +**`timeout` is its own status, not folded into `abandoned`.** `incomplete` +already establishes that this design does not treat every unsuccessful +outcome as a variant of `abandoned` — it gets its own status whenever the +cause is worth distinguishing. Applying that same idea to timeout means it +can be filtered or alerted on directly (`status = 'timeout'`) instead of +reaching into `metadata.abandonedBy`. `metadata.abandonedBy` still +distinguishes the two remaining causes folded into `abandoned`: +`'page-hidden'` for a scenario the page ended under the user, and `'caller'` +for a deliberate `cancel()`. `cancel(outcome?)` accepts the same +`CpsScenarioOutcome` shape as the other three settle methods, so custom +metadata and status codes are available if the caller wants to record +contextual details (which modal action triggered the cancel, for example). + +**RxJS pipeline integration (`traceScenario`).** For Observable-based flows +(HTTP fetches, dialog results), the pipeable `traceScenario` operator wires +Angular reactive pipelines directly into scenario lifecycle, with no manual +`tap`/`catchError` boilerplate — it settles on `complete` with optional +derived outcome metadata, and fails with the caught error on `error`. A +teardown that reaches neither — a superseding `switchMap`, `takeUntilDestroyed()`, +a manual unsubscribe — cancels the scenario instead, guarded by `isSettled` +so it never re-cancels one that already completed or failed (`tap`'s own +`unsubscribe` hook fires after every teardown, settled or not). Without +this, a cancelled-by-unsubscription scenario would sit active until its own +timeout and record as `timeout` rather than the caller-driven abandonment +it actually was. That cancellation carries no `reason` unless +`CpsTraceScenarioOptions.cancelOutcome` is supplied — a caller cannot +attach one itself by calling `scenario.cancel({ reason })` from a +`switchMap` projector, since `switchMap` unsubscribes (and thereby settles) +the prior inner Observable before the projector for the new value ever +runs. + +**Active registry leak detection.** In long-lived single-page applications +or kiosks, scenarios with `timeoutMs: 0` that get forgotten because of a +component lifecycle bug stay in memory until the page unloads. The service +issues a dev-mode warning once `active.size` passes 50, so authors notice +during local development. + +**No `started` or `in_progress` state.** The brief proposed both, but +nothing would ever actually query for one: a scenario has no status until it +settles, and nothing is emitted until then either, so a "running" value +would just sit unread between the two moments that actually matter. + +**Terminal states stay terminal.** Calling `complete()` on a scenario that +already failed is a silent no-op — nothing is sent, so nothing is +logged, even with `debugScenario` on (see §12). This matters +in real code: a `catch` block calls `fail()` and a `finally` block calls +`complete()`, and the scenario has to record the failure, not the last call +made. + +**Step closing is implicit.** Opening a step closes the previous one as +completed; settling closes whatever step is still open with the scenario's +own settling status — an unfinished step failed because its scenario +failed, not because of anything it did on its own. + +**Backdating.** `startedAt` moves the start earlier, in epoch milliseconds, +so a journey can be measured from the click rather than from the handler +that eventually runs. It is converted through `performance.timeOrigin` and +clamped to the page's lifetime; an unusable value falls back to now instead +of producing a negative duration. + +**Settling on paint is the caller's business.** A journey ending in a +render is not finished when the JavaScript is — but how long to wait for +pixels, and what to do when none arrive, is an application decision with no +single right answer. The library therefore settles nothing on paint, and ships no +helper for it either: two animation frames is a one-liner, and a caller who +needs a real paint can mark the element with `elementtiming` and observe +`element` entries with a `PerformanceObserver` — that is Chromium-only, so a +fallback is needed regardless. If nothing ever paints, the scenario is left +to its own timeout and settles as `timeout`, which is the truthful outcome — +the user never saw the result. + +A `completeOnNextPaint()` convenience method — one that resolves three ways +and records which one fired — is not offered, because two of the three +outcomes would be wrong in exactly the way that matters. A paint that never +happens would have to settle as **success** to fit that shape, inflating the +success rate exactly when rendering is worst. An observed-paint path, in turn, can +only timestamp the callback rather than the paint itself, so even the one +accurate route would still be inflated by dispatch latency. The example's +`stopAtNextFrame` gets both right — it cancels on timeout and backdates via +`overrideTimestamp` — but reproducing that faithfully would be more +machinery than the convenience is worth. + +**Timeout.** A scenario that never settles would silently vanish, inflating +the apparent success rate. Each one carries a timer (default 30s, +per-scenario overridable, `0` disables it) that settles it as `timeout`. + +**Unload.** On `pagehide`, every in-flight scenario settles as `abandoned` +with reason `page-hidden`, and the sink is flushed with `dispatchBeacon()`. +This is what makes abandonment measurable at all. Emission is synchronous +throughout, so everything settled here reaches the sink before the beacon +goes out. + +**Going hidden.** Mobile browsers routinely kill a backgrounded tab without +ever firing `pagehide`, so anything still buffered at that point would +otherwise just be lost. On `visibilitychange` going `hidden`, the sink is +flushed the same way — but scenarios are left running. Only `pagehide` +settles them, since the user may still come back. + +**Pause/resume is not implemented.** Nothing in scope needs a scenario that +spans app backgrounding, and pause handling — tracking paused duration, +adjusting every mark/step/settle path to consult it — is real complexity not +worth carrying for a capability nothing here actually uses. + +**Concurrency is per-instance, not per-name.** Each `start()` call returns +an independent `CpsScenario` with its own id; nothing checks whether +another scenario of the same `name` is already running, and nothing stops +one. This is deliberate, not an oversight: `name` is a metric dimension +(above), not an identity or a lock. Two browser tabs, two dashboard panels +loading at once, or two rows being edited independently can all +legitimately share a name while being genuinely unrelated journeys — the +library has no way to tell that apart from a caller starting the same +logical action twice, and guessing wrong in either direction would be +worse than not guessing. + +Where a caller _does_ know two `start()` calls are the same logical +action — a search box re-querying before the last query resolved, a +lazy-loaded table page reloading — the existing pattern is to track that +scenario locally and cancel it before starting the next one +(`scenario.cancel({ reason: 'superseded' })`, or `cancelOutcome` on +`traceScenario` for a `switchMap`-driven case where the direct call would +be a no-op — see Usage below). The library's role is to make that decision +inspectable, not to make it: `CpsScenarioTelemetryService` exposes +`find(scenarioId)`, `findByName(name)` (every active scenario sharing a +name), `findByNameAndId(name, scenarioId)` (an id lookup with a name +assertion), `getActive()`, and the `settled$` stream, so an application can +build whatever policy it needs — supersede, ignore, warn — on top of a +plain read of what's currently running. + +--- + +## 6. AWS mapping + +```mermaid +flowchart TD + A1["scenario.complete()"] --> S1["CpsTelemetrySink"] + A2["logger.error()"] --> S2["CpsLogApiProvider"] + A3["biTelemetry.track()"] --> S3["CpsTelemetrySink"] + + S1 --> R1["aws-rum-web: recordEvent('com.cps.scenario', …)"] + S3 --> R2["aws-rum-web: recordEvent('com.cps.bi', …)"] + + R1 --> RUM["AWS RUM"] + R2 --> RUM + S2 --> LOGAPI["the app's log API"] + + RUM --> CW["CloudWatch — app monitor log group<br/>(scenario events also power extended metrics)"] +``` + +### Verified against the installed SDK + +Checked against `aws-rum-web@3.2.1` as installed in this repository — not +against documentation. + +| Capability | Verdict | Evidence | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| Custom events | ✅ `recordEvent(eventType, eventData, metadata?)` | `@aws-rum/web-slim/dist/es/orchestration/Orchestration.d.ts` | +| Custom event attributes | ✅ third `metadata` argument, `Record<string, string \| number \| boolean>` | `@aws-rum/web-core/dist/es/plugins/types.d.ts` | +| Global event decoration | ✅ `setEventMetadataHook()` / `clearEventMetadataHook()` | same | +| Session attributes | ✅ `addSessionAttributes()` | same | +| Session identification | ✅ `getSessionId()`, `pinSessionId()`, `startSession()` | same | +| User identification | ✅ `getUserId()`, `pinUserId()` | same | +| Page / action tracking | ✅ `recordPageView()`, `registerDomEvents()` | same | +| Errors | ✅ `recordError()` | same | +| Performance measurements | ✅ via built-in `performance` telemetry + Web Vitals plugin | `WebVitalsPlugin`, `NavigationPlugin`, `ResourcePlugin` | +| Flushing / buffering | ✅ `dispatch()`, `dispatchBeacon()`; `dispatchInterval` 5s, `batchLimit` 100 by default, both configurable via `CpsRumAppMonitorConfig` | `Orchestration.js` defaults | +| Custom **metrics** | ❌ **not an SDK concept.** Emit events; define CloudWatch RUM _extended metrics_ server-side | +| Correlation identifiers | ⚠️ **no built-in scenario correlation.** X-Ray trace ids exist for HTTP; journey correlation is ours to design | +| Reading telemetry back from the browser | ❌ no export/read API | +| Offline behaviour | ⚠️ events sit in the in-memory cache (`eventCacheSize` 1000 by default, configurable) and are lost on tab close; there is no persistent queue | +| Web Worker execution | ❌ **not possible** — `SessionManager` reads `window.location.hostname`, `document.cookie` and `navigator.cookieEnabled`, none of which exist in a worker context | + +### The constraint that shaped the design + +SDK defaults, from `@aws-rum/web-slim/dist/es/orchestration/Orchestration.js`: + +``` +sessionEventLimit: 200 <-- hard cap on events per session +eventCacheSize: 1000 +batchLimit: 100 +dispatchInterval: 5000ms +sessionSampleRate: 1 +``` + +All five are now exposed on `CpsRumAppMonitorConfig` (§10) — an application +that knows its own traffic can deliberately raise `sessionEventLimit`. The +numbers above are still the _default_, and the design below assumes the +default. It describes what happens when nobody has opted into a larger +budget, not what the SDK allows in general. + +**200 events per session, across all telemetry, by default.** A running +scenario has no `started` event — nothing is emitted until it settles (§5) +— so a six-step scenario emitting one packed event at settlement spends +exactly 1 of the 200; with `emitLifecycleEvents: true` it would spend 7 (one +per step plus the final packed event, N+1 not N+2). Roughly thirty six-step, +lifecycle-emitting scenarios exhaust the default session budget — including +the budget for page views, web vitals, and JS errors, which then get +dropped. + +Consequently, **a scenario emits exactly one RUM event, at settlement**, with its steps +packed into the payload. That is the default, not just an option, and it +stays the default even though `sessionEventLimit` is now configurable: +raising the cap moves the ceiling, it does not change the shape of the +emission. An application that wants step-level events sets +`scenario.emitLifecycleEvents: true` and, if the wider event count needs +headroom, raises `sessionEventLimit` alongside it. Per-lifecycle-event +emission is meant for local debugging, with the cost documented here and in +§7. + +### Event types + +One event type per concern, with whatever varies carried as data: + +| Type | When | +| -------------------- | -------------------------------------------------------------------- | +| `{ns}.scenario` | Once per scenario at settlement (`status` distinguishes the outcome) | +| `{ns}.scenario.step` | Per step — verbose mode only | +| `{ns}.bi` | Per business/UX event (`eventName` carries the vocabulary) | + +Distinct types per transition (`scenario_started`, `scenario_completed`, …) +were considered and rejected: they'd multiply both the schemas to query and +the extended-metric definitions to maintain, where a `status` dimension does +the same job on a single schema. + +`{ns}` is `eventNamespace`, defaulting to `com.cps` (exported as +`CPS_DEFAULT_EVENT_NAMESPACE`, alongside the `cpsEventTypes()`/ +`CpsTelemetryEventTypes` helpers a custom sink can use to derive the same +`{ns}.scenario`/`{ns}.scenario.step`/`{ns}.bi` strings). It is configurable +because **event types are a contract with whatever already queries them** — +extended metrics, Logs Insights queries, dashboards. An application +migrating onto this library keeps its own namespace, and none of that has to +be rewritten: + +```ts +provideCpsTelemetry({ ..., eventNamespace: 'com.my-app' }); +// -> com.my-app.scenario / .scenario.step / .bi +``` + +If a dashboard is keyed on one specific legacy type rather than a +namespace, a single BI event can override its type: + +```ts +biTelemetry.track( + 'click', + { source: 'toolbar' }, + { + eventType: 'com.my-app.click' + } +); +``` + +That is a migration escape hatch, not a pattern to reach for — giving every +business event its own type is exactly what the single-type design is meant +to avoid. + +The `aws:` metadata prefix is reserved — the client drops any such key with +a console warning — so the sink filters them out before recording. + +### Which signals come from where + +| Layer | Answers | Source | +| ---------------------- | ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **RUM built-in** | Is the app fast? Is it erroring? Who is using it? | Automatic: page views, navigation timing, Web Vitals (LCP/FID/CLS/INP), JS errors, HTTP errors, resource timing, session start, Apdex-style perceived performance | +| **Scenario telemetry** | Does _this journey_ work, how long does it take, where does the time go, why does it fail? | Custom `{ns}.scenario` | +| **BI telemetry** | Is the feature used, by how many people, in what order? | Custom `{ns}.bi` | +| **Application logs** | What exactly happened during this one run? | `CpsLoggerService` → the application's `CpsLogApiProvider` | + +Nothing in the custom layers duplicates a built-in signal. The library +does not record page views at all, does not re-capture unhandled JS errors, +and does not collect browser or device attributes. + +### CloudWatch + +- **Custom RUM events do not automatically become CloudWatch metrics.** They + land in the app monitor's log group. To get a metric, you have to define a + RUM _extended metric_ that maps the event to a metric with dimensions. + That is server-side configuration, out of scope here — the frontend's job + is just to emit fields that are suitable as dimensions (low cardinality: + `scenarioName`, `status`, `feature`, `operation`) and values (`delta`, + `stepCount`). +- **Percentiles (P50/P75/P95/P99) are achievable**, two ways: CloudWatch + Logs Insights over the app monitor log group (`stats pct(delta, 95) by +scenarioName`), or a CloudWatch percentile statistic over an extended + metric. +- **Percentiles are never computed in the browser.** A single client only + sees a handful of samples; a percentile over them would be meaningless, + and shipping one would throw away the raw values needed to compute the + real figure. The frontend ships raw `delta` per scenario and lets AWS + aggregate it. +- **CloudWatch Logs and CloudWatch RUM are separate streams**, and they are + treated as such. They are joined analytically via `sessionId` and + `scenarioId`, not by any automatic AWS-side correlation. + +--- + +## 7. Metrics enabled + +### Reliability + +Derived from `status` over `com.cps.scenario`, grouped by `scenarioName`: + +- success rate — `success / total` +- failure rate — `failure / total` +- abandonment rate — `abandoned / total` +- timeout rate — `timeout / total` +- incomplete rate — `incomplete / total` +- error-category distribution — group by `statusCode` and `error.name` + +Each of these is a query on `status`, a low-cardinality dimension that +works well as a CloudWatch extended metric. Within `abandoned`, +`metadata.abandonedBy` separates a page unload from an in-application +cancellation; `timeout` is its own status and needs no such lookup. + +Keeping these apart is the whole point: a user navigating away mid-load +is not a defect, a search with no results is not a defect either, and folding +either of them into `failed` makes the failure rate useless for alerting. + +### Latency + +Whole-journey, straight from the packed event: + +- total scenario duration — `delta` +- perceived vs. scripted duration — for journeys where the caller settles on + an observed paint, `delta` covers what the user actually waited for + rather than when the JavaScript finished. Mark those scenarios in their + own `metadata` if you need to separate them from the rest + +Percentiles P50/P75/P95/P99 over either, computed AWS-side (§6). + +`endTime` is **not** a second duration: by construction it is +`startTime + delta` (as an instant, once both are ISO), so the gap between +them is just `delta` under another name. The pair exists to place a journey +on the wall clock — when it ran, and which journeys overlapped — not to +measure how long it took. `elapsed`, unlike either, is not about this journey +specifically at all — it is a timeline position (ms since the _host_ page +loaded, `cpsElapsedNow` — see "Fields that behave correctly across realms" +in §13), the same idea a step's own `elapsed` carries, and it is there so +events from one session can be lined up against each other without this +library having a session start time to work from. That proxy is inexact +across a hard reload: the RUM session cookie (`allowCookies` on by default, +30-minute default length) survives a reload that `performance.timeOrigin` +does not, so a `sessionId` can span an `elapsed` reset. + +**Within-journey latency is payload, not a dashboard metric.** +`steps[].stepDelta`, `aggregates[].elapsed` and `callCount` all ship on the +record and answer "where did the time go" in a Logs Insights query. They +cannot drive a CloudWatch metric while scenarios emit packed, because a +metric definition selects one value per event and a step array holds N. The +same is true of anything derived from them, including slow-step rate. + +There are two ways to get step-level metrics when they are worth their cost: + +- `emitLifecycleEvents: true` emits a `{ns}.scenario.step` event per step, + with `name`, `stepDelta` and `status` at the top level — directly usable + as a metric with dimensions. The cost is the session budget: a six-step + scenario spends seven of the 200 events — one per step plus the final + packed event — so a few dozen scenarios exhaust it, and later events, + errors included, get dropped. +- Keep packed emission and read step timings in Logs Insights instead, + accepting that they are a troubleshooting tool rather than something an + alarm watches. + +The top-level fields already answer the two questions most often asked of +step data without unpacking anything: `previousStep` says where journeys +stop, and `exceededStepsLimit` says when a step list was truncated and +should not be read as a short journey. + +### Usage + +- scenario count, starts and completions — event counts by `status` +- scenario frequency — counts over time +- unique users — RUM's own `userId` dimension +- feature adoption — `com.cps.bi` counts by `eventName` and `feature` + +### Additional signals evaluated + +| Signal | Kept? | Reasoning | +| ------------------------------------------ | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| retry count | ❌ | Evaluated and not collected. A retry is a step like any other, so `stepCount` and repeated step names already carry the signal; a dedicated counter would mean adding a `retry()` method this design has no other reason to want | +| number of steps | ✅ `stepCount` | Detects runaway loops and identifies which code path ran | +| success-after-retry | ❌ | Follows retry count out: without a counter there is nothing to derive it from | +| slow-step rate | ⚠️ derived, verbose only | Thresholding step `stepDelta` needs one event per step, so it is only a metric under `emitLifecycleEvents`; packed, it is a Logs Insights query | +| error-category distribution | ✅ `statusCode` + `error.name` | Separates "backend 500" from "client-side type error" | +| client/application version | ✅ session attribute | Attributes a regression to a release | +| network/API dependency failures | ✅ | Already covered by RUM's built-in HTTP telemetry — not duplicated | +| scenario version / feature version | ❌ | Folded into `feature` plus the application version; separate version fields on every event would be payload with no distinct question behind them | +| time spent per step (as a separate metric) | ❌ | That _is_ `steps[].stepDelta` — carried on the record, queryable, but not a dashboard metric while packed (see above) | +| aggregate operation totals | ✅ `aggregates[]` | Repeated work — a formatter per row — where the total matters and per-call steps would be noise. Payload for queries, not a metric, for the same array reason | +| truncation awareness | ✅ `exceededStepsLimit` | A dashboard should not need to know the configured `maxSteps` to spot a partial step list | +| paint-aware duration | ⚠️ caller-driven | A caller may settle on a real paint of its own; the library records no paint field of its own, having removed one that was wrong on two of its three paths | + +Nothing here is collected "for completeness" — each field answers a +troubleshooting or product-health question stated above. + +--- + +## 8. Correlation + +```text +user (userId) + └── session (sessionId, from the RUM client) + ├── scenario (scenarioId) + │ ├── steps + │ └── BI events carrying that scenarioId + ├── BI events (standalone) + └── logs (correlationId = scenarioId) +``` + +| Identifier | Origin | Purpose | +| --------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------- | +| `userId` | Application, via `CpsTelemetrySink.setUserId` → `pinUserId` | Unique users; cross-session journeys | +| _sign-out_ | `setUserId(undefined)` → `startSession({ userId: <fresh uuid> })` | Stops attributing later events to whoever just left | +| `sessionId` | The RUM client (`getSessionId`), or the shell in a follower realm | Joins logs to the RUM stream — deliberately _not_ minted separately | +| `scenarioId` | `cpsUuid()` per scenario | The join key across telemetry, logs, and the backend | +| `correlationId` | A log field, normally set to a `scenarioId` | Ties log lines to a journey | + +Both `userId` and `sessionId` live on the sink, and that is the point: the +logger reads them from there whenever it stamps a record, so a log line and +a RUM event can never disagree about who is signed in or which session this +is. There is no separate context service holding its own copy of `userId` — +a second place for the same value to live would only ever drift out of sync +with the sink's own. Sign-out is `setUserId(undefined)` — or `''`, which +identifies nobody and is never pinned as an id that every such user would +share — and correlation +runs only through `getSessionId()`/`setUserId()`. + +The API makes correlation the path of least resistance rather than +something to remember: + +```ts +const scenario = scenarioTelemetry.start({ name: 'load-customer-data' }); + +this.logger.error('Failed to load customer data', { + correlationId: scenario.id +}); +``` + +The scenario never logs anything itself and holds no logger, so scenario +telemetry never requires the logging stack to be configured at all — the +two concerns meet only through this id. + +**Backend correlation is by convention, not magic.** AWS performs no +automatic cross-system correlation. Send `scenario.id` to the backend as a +request header (e.g. `X-Correlation-Id`) and have backend logs record it +under the same name — the join then works on its own. The library +deliberately does not ship an HTTP interceptor for this — header naming and +which hosts may receive the header are application decisions to make. + +--- + +## 9. Privacy + +### Rules + +1. **Metadata is flat and primitive-only.** `CpsTelemetryMetadata` is + `Record<string, string | number | boolean | null>`. It is the type + system, not a runtime heuristic, that stops an application from passing + a response body, a `User` object, or a DOM node into telemetry. Values + that reach the runtime through an `any` are dropped, not flattened. +2. **Arbitrary objects are never serialized.** There is no recursive + serializer to mis-tune. Objects, arrays, functions, symbols and + `undefined` are all dropped. +3. **Denylisted keys are redacted**, case-insensitively, matching + `pass(word|wd)?`, `secret`, `token`, `auth`, `credential`, `cookie`, + `api[-_]?key`, `bearer`, `jwt`, `signature`, `session[-_]?key`, `ssn`. + Applications can extend the list via `redact.extraKeyPatterns`. +4. **URL query strings and fragments are stripped**, by default, from every + string value, including URLs embedded inside error messages. Query + strings routinely carry access tokens, one-time links, search terms, and + record identifiers. This is configurable off via + `redact.stripUrlQuery: false`. +5. **Value-content PII shapes can be scanned for, opt-in.** The key + denylist above only catches PII living under a conventionally-named + key; it cannot see a value that happens to be an email or a card number + sitting under an innocuous key like `notes`. `redact.scanValuePatterns` + (default `[]`, off) enables one or more of `'email'`, `'creditCard'`, + `'ssn'`, `'ipv4'`, `'phone'` — each is a heuristic regex, not a + certified detector, run independently of rule 4 (a value is scanned + as-is if `stripUrlQuery` is off). See "Value-content scanning" below for + why these five and not a broader set. +6. **Errors are normalized** to `{ name, message, stack? }`. A raw + `HttpErrorResponse` carries its entire response body; only these three + bounded fields survive. +7. **Everything is size-capped** — strings 1024 chars, stacks 2048, 50 keys + per payload. Truncation, never a throw. +8. **Scenario and BI names are treated as metric dimensions.** They are + documented as low-cardinality; never interpolate a record id into an + event name. +9. **A consumer can layer its own scrubbing on top, opt-in.** + `redact.extraValueTransforms` (default `[]`) runs application-supplied + `(value: string) => string` functions on every string value, after + everything above — an escape hatch for redaction logic no regex can + express (rules 3-5 are all pattern-matching; this is arbitrary code). + Runs independently of `scanValuePatterns`/`extraValuePatterns`: a + consumer wanting only its own logic doesn't have to enable the built-in + patterns to get it. A throwing function is skipped (logged in dev mode) + rather than blocking the rest of the pipeline — see §11's fail-open + posture. + +### Never collected + +Passwords, authentication or access tokens, cookies, and raw +request/response bodies. The type system already rules out request/response +bodies (rule 1: metadata is flat and primitive-only, so an object never +reaches telemetry to begin with), and the key denylist (rule 3) catches +conventionally-named password/token/cookie/credential fields regardless of +their value shape, with no configuration required. + +Everything else — email addresses, usernames, account numbers, or any other +personal data sitting under an unconventional key — is **not** covered by +default. `scanValuePatterns` (rule 5) closes the gap for exactly five value +shapes (email, credit card, SSN, IPv4, phone), is off by default, and has no +shape at all for usernames or account numbers — see "Assumptions" below for +what stays the application's own responsibility. + +### Assumptions + +**Sign-out starts a new session.** `pinUserId` has no inverse, so clearing +the signal alone would leave the client attributing every subsequent event +to the person who just left — on a shared device, to the wrong person +entirely. The SDK's `startSession` is documented for exactly this case +("sign-in, sign-out, kiosk handoff"), so `clearUserId()` starts a fresh +session with a fresh anonymous id. The cost is one `session_start` event and +a re-rolled sampling decision. + +- Applications pass **opaque** user identifiers to `setUserId`. If that + identifier happens to be email-shaped and `scanValuePatterns: ['email']` + is enabled, it is now caught like any other value — but the library still + cannot tell that a non-email-shaped identifier (an internal user id, for example) + is personal; the API documents this requirement regardless. +- Applications choose their own metadata keys responsibly. The denylist + catches conventional names, not a key called `x1` — value scanning + narrows this gap for the five shapes it covers, but does not close it for + PII with no recognizable shape (a bare name, for example). +- Stack traces are assumed not to contain personal data. They can be + disabled entirely with `redact.includeStack: false`. +- RUM's automatic metadata (country code, browser, device) is treated as + non-personal. + +### URLs are the weak point + +A path can carry personal data — `/customers/john.smith@example.com` — and +`cpsScrubString` does not catch it. That function strips query strings and +fragments (`PATH_WITH_QUERY` only fires on `?` or `#`); a path _segment_ +passes straight through. A field claiming to be scrubbed while still +carrying a route parameter is worse than one that makes no such claim at +all. + +Value-content scanning (`redact.scanValuePatterns`, below) closes this exact +case when the shape is recognizable: `cpsScrubString` runs value-pattern +matching over the string _after_ the URL-query scrub, not just before it, +so `scanValuePatterns: ['email']` does catch `john.smith@example.com` +sitting in that path segment. It is a narrowing, not a fix for the general +problem — an opaque numeric or alphanumeric customer id in the same +position has no recognizable shape and still passes through untouched. The +remedy below ("What the library cannot fix") is still the complete answer +for that case. + +Three things follow from this. + +**Page views are the RUM client's own, and the library adds nothing.** The +client's `PageViewPlugin` is installed unless `disableAutoPageView` is set, +and it patches `History.prototype.pushState` / `replaceState` and listens +to `popstate`, recording `location.pathname` on each. Angular navigates +through `pushState`, so every route change is already a page view with no +wiring at all needed. + +There is deliberately no `provideCpsRouterPageViews()` calling +`recordPageView` with the route template on `NavigationEnd`. The client's +own plugin stays enabled either way, so a second recorder on top of it +would produce **two** page views per navigation, double the `interaction` +counter, and leave the first page with a `timeOnParentPage` of roughly zero +— while the resolved path is still what the client's own plugin records +regardless, so a route-template recorder would not even achieve the privacy +goal it might seem to serve. + +What remains true is the reason someone might want templates. +`EventCache.createEvent` merges the current page attributes into **every** +event it records — + +```js +const eventMetadata = { ...pageAttrs, ...hookOutput, ...sanitizedManual }; +``` + +— so the page id reaches the envelope of every scenario, BI event, JS +error, and web vital that follows it, and with `pageIdFormat: 'PATH'` that +page id is the resolved path. An application whose routes carry +identifiers should therefore derive the template itself, call +`CpsRumTelemetrySink.recordPageView` with it, **and** set +`disableAutoPageView: true` — the library ships no route-template helper of +its own, so both halves of that pairing are the application's to provide. + +Anyone deriving a route template should know the trap to avoid: a route +declared with `matcher` has no `path` at all, so walking +`routeConfig.path` reports every such route as `/` — in an application +where most routes are declared that way, that is the entire site collapsing +into one page. The template has to come from the segments the match actually +consumed, with anything bound as a parameter put back as `:name` so the +value cannot escape. + +**BI events carry no `route`.** With `pageIdFormat: 'PATH'` (the default), +the client's `pageId` _is_ `location.pathname`, so the field would just be +the same value sent twice, and a second way for a parameter to escape. + +**Scenario `route` is documented as a template.** It survives because it is +captured at `start()`, and is therefore genuinely different information — +where the journey began, versus where the page id says it ended. The +library cannot enforce the template form; the field's documentation states +it, and the application supplies it. + +**What the library cannot fix.** The RUM client captures `pageUrl` itself +from `location.href`, at a layer below anything here. If paths carry +personal data, that URL still gets shipped. The remedies are AWS-side or +application-side: `pagesToExclude` / `pagesToInclude` to stop recording +those pages, or a URL design that keeps identifiers out of paths. Stated +plainly here rather than listed as an accepted assumption. + +### Value-content scanning + +There is no single canonical "PII redaction standard" this triangulates +against — the reasoning below draws on a few: + +- **OWASP Logging Cheat Sheet** lists the same categories the key denylist + already targets (credentials, tokens, session data, regulated personal + data) and recommends masking, which is the approach already taken here; + it does not mandate specific regexes, so it shapes the _what_, not the + _how_. +- **PCI DSS Requirement 3.4** requires that a Primary Account Number + (credit or debit card number) never appear in cleartext logs — the + standards-backed reason `'creditCard'` is in the built-in set at all, not + just "email would be nice." +- **NIST SP 800-122** ("Guide to Protecting the Confidentiality of PII") + gives a broad PII definition that names SSN and financial account + numbers as high-priority examples, supporting `'ssn'`'s inclusion + alongside `'creditCard'`. +- **AWS Comprehend's PII entity types and Microsoft Presidio's default + recognizers** are not regulations, but they are the closest thing to an + industry-common list of which value shapes a lightweight scanner + typically covers (`EMAIL_ADDRESS`, `CREDIT_CARD`, `US_SSN`, + `IP_ADDRESS`, `PHONE_NUMBER`, …). Borrowed here for naming and scope, + deliberately **not** for approach — both are full ML/NER services, the + wrong weight class for a synchronous, browser-side pass that has to stay + at "microseconds" (see §11, Performance). `scanValuePatterns` is + regex-only, on purpose. + +**Off by default, opt in per shape** — matching this config's own existing +convention (`CPS_DEFAULT_REDACT_CONFIG`'s own doc comment: "Conservative by +design — widen them deliberately"). No consumer sees a behavior change +unless it opts in, and the check is skipped entirely (no array iteration) +for the zero-config default, so the "microseconds, shallow pass" claim +still holds for anyone who has not turned this on. + +`'creditCard'` is handled differently from the other four: a plain regex +cannot express a Luhn checksum, so candidate 13-19 digit runs are validated +against one before being redacted. This is a real precision/recall trade, +made deliberately: without it, any order number or internal id of the +right length would get redacted too, which is worse for data utility than +the (cheap, one-pass, no allocation) checksum is for performance. + +`'phone'` is named explicitly as the highest false-positive-risk pattern in +its own doc comment — any sufficiently number-like string collides with +it. It still ships, opt-in, rather than being left out entirely, because +the alternative — silently excluding it — would just move the decision +from the application (who knows whether phone numbers are a real risk in +its own metadata) to this library (who does not). + +### Direct CloudWatch reads from the browser + +**Technically possible; recommended against, and not implemented.** + +A browser could call CloudWatch Logs with `@aws-sdk/client-cloudwatch-logs` +using Cognito or STS credentials. Doing so would require granting +`logs:StartQuery` / `logs:FilterLogEvents` to credentials held in the +browser, which means: + +- any user could read **every** tenant's logs in that log group — log + groups have no row-level authorization; +- there is no server-side place to enforce who may see what; +- the credentials are extractable from the browser and reusable outside the + app; +- log-query costs become user-controllable. + +Log retrieval belongs behind a backend API that authorizes the request and +returns only that user's own data. The frontend's credentials are scoped to +_writing_ RUM events, nothing else. + +### Downloading logs to a file from the browser + +The brief asks whether this is possible. It is, but **not through AWS**, +and the distinction matters because the two routes solve different +problems. + +_Through AWS_ — technically available via `@aws-sdk/client-cloudwatch-logs`, +and rejected for every reason above. It also answers the wrong question: by +the time a record is in CloudWatch it is minutes old and mixed in with +everyone else's, which is rarely what someone asking for "the logs" +actually wants. + +_From the page_ — `CpsLoggerService.query()` returns records from the +application's own backend, which the application can serialize to a file +however it likes. No AWS, no credentials: if the backend is local the +records never leave the tab, and if it is remote the application has already +authorized the request. This is the useful answer for a developer +reproducing something, or for a support flow that asks a user to attach +their session's logs to a ticket. + +For logs that have already been shipped, retrieval stays a backend concern +— the same authorized API described above. + +### The diagnostics popup + +The popup is available in every environment and shows **exactly what is +sent, after redaction** — no further redaction of its own, since a popup +that hid fields would misreport what leaves the browser, which is its only +job. That is acceptable in production because it exposes nothing new: the +data belongs to the current user's own session, already sits in their +browser, and is equally visible in the network panel and through the debug +flags. The popup stores nothing, sends nothing, and grants no remote +access. + +What remains is ordinary care, stated in the popup's own footer: session +and user ids are on screen (avoid screen-sharing), and downloads contain +the same redacted data as the view — no more, no less — with filenames that +carry only the application name and a timestamp. The keyboard shortcut +only prevents accidental opening; it is not access control. An application +that must not show even redacted telemetry to its users can pass +`enabled: () => …` to limit access, or leave the provider out. +--- + +## 10. Configuration + +```ts +provideCpsTelemetry( + // Identity: mandatory, shared verbatim by every concern below — an + // application's environment cannot honestly be 'prod' for its logs and + // 'staging' for its scenarios, so this is one shared fact, not one per + // concern. + { + application: 'my-app', // required + environment: 'production', // required + version: '22.0.0', // required + + // Prefix for the emitted event types. Set it to keep an existing + // CloudWatch contract when migrating an application onto this library. + eventNamespace: 'com.cps' + }, + + // Every concern below is an optional, independently omittable feature — + // an application configuring only logging never has to think about + // scenarios, BI events, or redaction. + withScenarios({ + defaultTimeoutMs: 30_000, + emitLifecycleEvents: false, // see the session event budget in §6 + maxSteps: 50, + userTimings: false, // also switched on by the debugScenario flag + markCleanupFallbackMs: 300_000, // see "Marks are normally cleared..." below + redact: true // per-concern opt-out — see "Turning redaction off..." below + }), + withLogging({ + minLevel: 'log', + mirrorErrorsToRum: false, + redact: true + }), + withBiEvents({ + dedupWindowMs: 400, + dedupMaxKeys: 100, + redact: true + }), + withRedaction({ + extraKeyPatterns: [], + maxStringLength: 1024, + maxKeys: 50, + maxStackLength: 2048, + includeStack: true, + stripUrlQuery: true, + scanValuePatterns: [], // opt-in value-content PII scanning — see §9 + extraValuePatterns: [], + extraValueTransforms: [] // opt-in custom scrubbing functions — see §9 + }) +); +``` + +**Why one `provideCpsTelemetry()` call, composed with `with*()` features,** +instead of either a single flat config object or a fully separate +`provide*` function per concern. This mirrors Angular's own +`provideHttpClient(withInterceptors(...), withJsonpSupport())`, a pattern +not previously used anywhere in this codebase before this design: + +- Identity is mandatory for any use of the library at all — every log + record, scenario record, and BI event carries it. That requirement + does not go away under any provider shape, so something has to supply it + once. A fully separate function per concern + (`provideCpsTelemetryLogging({ application, environment, version, +minLevel, ... })`, `provideCpsTelemetryScenarios({ application, +environment, version, maxSteps, ... })`) would mean either restating + identity at every call site — the same fact, in multiple places, free to + silently drift apart — or each function quietly depending on a separate + identity provider underneath, which is real DI-ordering ceremony for a + capability composable features already give for free. +- Each concern still gets its own DI token (`CPS_LOG_CONFIG`, + `CPS_SCENARIO_TELEMETRY_CONFIG`, `CPS_BI_TELEMETRY_CONFIG`, `CPS_REDACT_CONFIG`), so a + consumer overriding one directly through DI substitution — a test, a + runtime-computed value — can target that token alone, without + reconstructing the whole identity or touching unrelated concerns. A + single flat `CpsTelemetryConfig` object bundling every concern under one + token was the library's original shape; splitting the tokens is what + makes "configure logging independently of scenarios" literally true at + the DI layer, not just true of the input object's optional sub-fields. +- `provideCpsTelemetry()` provides a library default for every one of + these tokens unconditionally, so each `with*()` call — spread into the + same providers array after the defaults — simply replaces its own + token's provider, using the same override-by-last-registration mechanic + Angular's own DI already uses everywhere else. No service ever needs an + optional-injection fallback for a narrow token: whether or not an + application calls `withLogging(...)`, `CPS_LOG_CONFIG` is always bound. + +Telemetry infrastructure is kept separate from application configuration: +the config above contains no AWS account details. Those arrive through +`CPS_RUM_CREDENTIALS_PROVIDER`, implemented by the application. +`CpsRumAppMonitorConfig` (passed as `config` in the `CpsRumBootstrap` a +`CpsRumCredentialsProvider` returns) exposes nearly all of `aws-rum-web`'s +own configuration surface, grouped and documented field-by-field in its own +JSDoc — see the README for a worked example of the advanced fields. + +Optional providers, each isolating a dependency: + +- `provideCpsTelemetryRumSink()`, from the `cps-telemetry/rum` entry point — + the only thing that pulls in `aws-rum-web` + +Nothing pulls in `@angular/router` at all, so it is not a peer dependency. + +`provideCpsTelemetry` alone is deliberately **not** functional on its own: +it registers configuration and no destination, so injecting a telemetry +service without `provideCpsTelemetrySink(...)` and a `CPS_LOG_API_PROVIDER` +fails on first use. + +Defaulting them to `CpsNoopTelemetrySink` and an in-memory transport would +let an application forget to wire a destination and still run perfectly +while shipping nothing — invisible until somebody asks why the dashboard is +empty. It would also make the `'noop'` mode pointless, since the default +would already do silently what that mode exists to state out loud. A +missing provider is a configuration error caught on the first run, not a +runtime failure; the guarantee that telemetry never breaks the application +is about a sink or transport _throwing_, which remains fully guarded +regardless. + +`CpsLoggerService` is the one deliberate exception to the sink half of this +rule — `inject(CpsTelemetrySink, { optional: true })` rather than a hard +dependency. The reasoning above still holds for +`CpsScenarioTelemetryService`/`CpsBiTelemetryService`, whose entire purpose +is reaching a sink: for those, a missing provider silently doing nothing at +runtime is exactly the failure mode this design exists to prevent. Logging +is different in kind, not just degree: its actual destination is +`CPS_LOG_API_PROVIDER`, which keeps the identical hard-fail guarantee +untouched — a missing log API provider still fails `CpsLoggerService`'s +injection outright. The sink there is only an enrichment source +(`sessionId`/`userId` correlation with RUM, optional `mirrorErrorsToRum` +mirroring), not where logs actually go, so an application that only wants +structured logging is not forced to configure a sink — not even `'noop'` — +purely to satisfy a dependency it never uses for its actual output. + +Entry names are `<application>:<scenario>:<boundary>:<scenarioId>`. The +prefix is the consuming application's own name rather than this library's: +a Timings track already carries the framework's entries and the +application's own, and knowing which _library_ emitted an entry is never +the question a developer is actually asking. It also keeps two realms of a +composed page apart, which one library-wide prefix could not do. + +`userTimings` is worth calling out on its own: it is off by default because +nothing consumes `performance` entries in production, but the +`debugScenario` LocalStorage flag turns it on regardless of configuration. +That is deliberate — a developer investigating a deployed build cannot change +config, which is the entire reason the debug flags exist at all. + +Marks are normally cleared at settle time. However, a scenario with its timeout +disabled (`timeoutMs: 0`) that never settles and never sees `pagehide` (no +navigation, no tab close) has no settle event to clear them at. `CpsScenario` +guards this specific gap with its own independent fallback: when +`scheduleTimeout` finds no real timeout to rely on, it schedules a separate +timer — `scenario.markCleanupFallbackMs`, default 5 minutes — whose only job +is clearing the marks; it never settles the scenario or touches its status. +A scenario with a real timeout, however long, never gets this fallback +scheduled at all: its own timeout already guarantees a settle, and +therefore a cleanup, on its own schedule, and racing a second timer against +it would either be redundant or (if the fallback were shorter) wipe out the +marks of a scenario that is still legitimately running. + +The default is deliberately generous rather than aggressive, for the same +reason: `timeoutMs: 0` is an explicit opt-out of any time-based cutoff, so +the population this fallback applies to skews toward long-running +_legitimate_ work (uploads, long polls) — exactly what a short default +would routinely misfire on. All this has to bound is a genuine leak (a +scenario abandoned by a bug and left for the life of the page), which 5 +minutes catches just as surely as 60 seconds would, without the false +positives. + +Deliberately not settling the scenario itself: `timeoutMs: 0` is the caller +explicitly asking for no auto-settlement, and silently overriding that on a +timer would be a worse surprise than the leak it fixes. One consequence +worth naming — a scenario that takes this path and is never manually +settled also never fires `onSettled`, so it stays in +`CpsScenarioTelemetryService`'s active registry, and in memory, for the +life of the page, not just for as long as its marks do. This only matters +for a scenario abandoned by a bug and left to accumulate; one a caller does +intend to settle itself is unaffected. + +Deliberately not derived from `defaultTimeoutMs`: the two cannot be tied +together, since the common way to reach this fallback at all is +`defaultTimeoutMs: 0` itself — there'd be nothing to derive from. `0` +disables the fallback outright, matching `defaultTimeoutMs`'s own +convention, for an application that wants the pre-fallback behavior back. + +### Turning redaction off per concern + +`redact: boolean` (default `true`) lives on each of `CpsLogConfig`, +`CpsScenarioTelemetryConfig` and `CpsBiTelemetryConfig` — not on `CpsRedactConfig` itself — +so `withLogging`/`withScenarios`/`withBiEvents` can each opt a concern out +independently, matching every other field in this section being configured +per concern through its own `with*()` call rather than centrally. + +`CPS_REDACT_CONFIG` stays the single shared token either way (§9's +reasoning for keeping identity and redaction shared, not duplicated, is +unaffected): each service resolves its own effective config once, via +`cpsRedactConfigFor(inject(CPS_REDACT_CONFIG), thisConcernsOwnRedactFlag)`, +rather than the token itself varying per concern. `cpsRedactConfigFor` +returns the injected config unchanged when the flag is `true`, and a +derived variant with `extraKeyPatterns`, `scanValuePatterns`, +`extraValuePatterns` and `stripUrlQuery` all cleared when it's `false`. + +Turning a concern's redaction "off" is deliberately narrow: it only skips +the scrubbing that is actually configurable — `extraKeyPatterns`, +value-pattern scanning (rule 5), and URL-query stripping (rule 4). Four +things stay on no matter what, and this flag cannot touch them at all: the +built-in credential denylist (rule 3), size caps (rule 7), error +normalization (rule 6), and any `extraValueTransforms` (rule 9). The +denylist check in particular is hardcoded inside `isDenied()` — it never +reads from `CpsRedactConfig`, so there is no setting for a per-concern flag +to even switch off. + +This is intentional, not something that was missed. `CPS_REDACT_CONFIG`'s +own doc comment already treats size caps and error normalization as +guarantees, not options — the same reasoning applies to the credential +denylist. The `redact` flag exists so a consumer can quiet down false +positives (e.g. an aggressive URL-stripping rule mangling their own data), +not to remove the one check standing between a stray `password` field in +someone's metadata and it showing up in CloudWatch. If `redact: false` +could disable that too, one careless call would quietly open a real +security hole — so it cannot. + +--- + +## 11. Error handling and performance + +**Telemetry cannot break the application.** Every public entry point is +wrapped in `cpsSafe`/`cpsSafeVoid`, which never rethrows. It does not, +however, _silently_ swallow errors: in development (`isDevMode()`) the suppressed +error is reported to `console.error`, so bugs in this library still surface +during development and in tests. In production it stays silent. The +console report is itself wrapped in a try/catch — an application that has +patched or otherwise broken `console.error` cannot turn a suppressed +telemetry failure into a rethrow, in `cpsSafe`, or into a fresh unhandled +rejection, in `cpsSafeVoidMaybeAsync`'s async path. Tests +assert both halves, and assert that a sink or transport throwing on every +call leaves application code unaffected. + +**Performance.** Deliberately not over-engineered: + +- One RUM event per scenario, not per step (the packed model). +- BI events deduplicated within a 400ms window, absorbing double-fires from + a handler bound to both `click` and `keydown`. Two events count as the + same only if their name, scenario correlation, event-type override, + feature, and metadata content all match — differing in any one of those + is a distinct event, not a duplicate. +- Redaction is a single shallow pass over a flat object. +- Durations use `performance.now()`, which is immune to wall-clock + adjustments — a sleeping device would otherwise produce negative or + wildly inflated durations. +- Batching, retry and network dispatch of already-recorded events are left + to the SDK, which already does them well. +- **Credential refresh** is this sink's own responsibility, and never fires + immediately: a broker returning already-expired or near-expiry + credentials falls back to the same bounded retry delay used for a failed + refresh, so a broker stuck returning bad credentials can't tight-loop + the sink. A refresh returning `null` — the documented session-disable + signal on `CpsRumCredentialsProvider.load()` — tears the client down and + stops scheduling further refreshes, instead of retrying forever against + an already-disabled sink still holding a stale, capped event buffer. The + very first load honors the identical contract: `performInit()` sets the + same `disabled` flag when the broker declines before the client is ever + constructed, not only on a later refresh — otherwise every `record()` + call would buffer into the capped pre-init queue forever instead of + becoming the clean no-op a deliberately-disabled session should be. +- A bounded 100-item pre-init buffer preserves bootstrap telemetry — + events, page views, and errors alike, replayed through the same code + path once the client is ready — without growing without limit if init + never completes. +- **The diagnostics monitor costs one check per event while unobserved.** + Every hand-off site calls `CpsTelemetryMonitor.publish()` after the + hand-off; with no subscriber it returns before copying anything. The + diagnostics popup subscribes only while it is open, keeps at most 500 + events per section, batches screen updates to one every 250 ms, and + releases everything when it closes. + +--- + +## 12. Usage + +### Bootstrap + +```ts +import { + provideCpsTelemetryRumSink, + CPS_RUM_CREDENTIALS_PROVIDER +} from 'cps-telemetry/rum'; + +providers: [ + provideCpsTelemetry({ + application: 'my-app', + environment: 'production', + version: packageJson.version + }), + provideCpsTelemetryRumSink(), + { + provide: CPS_RUM_CREDENTIALS_PROVIDER, + useExisting: AppRumCredentialsProvider + } +]; +``` + +### Scenarios + +```ts +const scenario = this.scenarioTelemetry.start({ + name: 'load-customer-data', + feature: 'customers' +}); + +try { + scenario.step('fetch-data'); + const rows = await this.api.fetchCustomers(); + + scenario.step('render'); + this.rows.set(rows); + + scenario.complete({ metadata: { rowCount: rows.length } }); +} catch (error) { + scenario.fail({ error }); +} +``` + +### BI events + +```ts +biTelemetry.track('export_clicked', { + exportType: 'csv', + source: 'customer-table' +}); + +// correlated to a journey +biTelemetry.track( + 'export_clicked', + { exportType: 'csv' }, + { + scenarioId: scenario.id + } +); +``` + +### Logging + +```ts +private readonly logger = inject(CpsLoggerService).getLogger('checkout'); + +this.logger.error('Failed to load customer data', { + correlationId: scenario.id +}); +``` + +### Debugging + +```js +localStorage.setItem('debugLogger', 'true'); +localStorage.setItem('debugScenario', 'true'); +localStorage.setItem('debugBI', '1'); +``` + +All three are off by default, accept only `'true'` and `'1'`, work in every +environment, and are read on each emit — so a DevTools toggle takes effect +without a reload. No production configuration switch is involved. + +Console lines are prefixed `[<application>][<concern>]` — the same +application-first rule as the User Timing entry names above, and for the +same reason: a composed page's realms all write to one console, so which +application emitted a line is the question being asked, and a single +library-wide prefix could not answer it. For logs the concern segment is +the logger name, followed by `[<context>]` when the record carries one: +`debugLogger` filters by logger name, so a line that omitted it would +leave a multi-logger filter interleaving output with no way to attribute +it. Each line's second argument is the +literal payload handed to the sink or the log transport, which is what makes +a console capture a faithful record of what ships rather than a summary of +it. + +For scenarios this is structural, not a convention to be maintained. A +single private `emit()` both logs and records, and it is the only method +in `CpsScenario` that touches the console — so the console shows one line +per event sent, carrying that very object, and there is no code path that +could print a step, a start, or a progress note that never went anywhere. +Opening a step, starting a scenario, and calling a mutator after +settlement all send nothing, and so print nothing. What a developer reads +in DevTools is the wire, not a narration of it. + +### Diagnostics popup + +```ts +import { provideCpsTelemetryDiagnostics } from 'cps-telemetry/diagnostics'; + +providers: [provideCpsTelemetryDiagnostics()]; +``` + +⇧⌥⌘8 on macOS, Ctrl+Alt+Shift+8 on Windows and Linux, in every environment. +It shows the same thing the debug flags print — every event as handed +over — but without DevTools, in three sections. Each section filters by any +field and downloads as JSON on its own; pause and clear apply to all three. + +A download holds one section, not the whole capture: `section` names it, +`events` lists every event that section captured (oldest first, regardless +of its filters), and `activeFilters` records the filters that were showing. +Ordering across sections is kept by each event's `sequence` and +`capturedAt`, so two downloads from one session can still be interleaved. + +**It observes; it never sends.** Each hand-off site publishes the object it +just sent to `CpsTelemetryMonitor` (main entry) _after_ the hand-off, in +the same fail-open block, so a hand-off that throws is not shown and +nothing is ever sent twice. Five sites publish: +`CpsScenario.emit()` (records and step events), `CpsBiTelemetryService`, +`CpsLoggerService` delivery to the log provider, its `mirrorErrorsToRum` +error, and `CpsTelemetryBroadcastHost` forwarding a fragment's events and +log records. A +subscriber gets a deep copy, because the step event shares objects with the +live scenario and the RUM sink buffers by reference. Alternatives were +rejected: decorating `CpsTelemetrySink` in DI would fight the +`useExisting` ordering of the sink providers and still miss logs, which go +to the log provider; `settled$` covers only settled scenarios. + +**Why the shortcut is what it is.** Four keys, ending in a digit, matched +on `KeyboardEvent.code` — Option rewrites `KeyboardEvent.key` on macOS +(⇧⌥8 is `°`) and layouts move digits, while `code` names the physical key. +Every modifier must match exactly, held-down repeats are ignored, and AltGr +is rejected: on many European Windows layouts it reports as Ctrl+Alt, and +typing an AltGr character must never open the popup. ⌥⌘8 alone is macOS +Accessibility Zoom, so Shift is part of it. The listener sits on `document` +in the capture phase, outside Angular's zone. + +**Non-modal on purpose.** The point is to watch events while using the app, +so the popup docks right at half the screen and leaves the app clickable; +it is draggable, resizable and maximizable. With the popup open, the +shortcut returns focus to it from the app and closes it from inside — +that is how a keyboard user moves between a non-modal window and the page. + +**Light theme only, for now.** The popup is designed and tested in the +light theme. cps-dialog, which frames it, is light-only, and the kit's dark +theme is not yet complete for the components inside it, so dark mode is +not supported. + +--- + +## 13. Multiple realms — micro-frontends and web fragments + +A composed page may run the shell and each fragment in its own JavaScript +context. [Web Fragments](https://web-fragments.dev), for example, "utilizes +a hidden iframe to create a clean JavaScript context that is used to load +and evaluate all of the scripts of the application" — the iframe itself is +never rendered; its DOM output is reprojected into a Shadow Root in the +host document, so what the user sees is ordinary Shadow DOM content in the +host page, not a visibly embedded frame. The iframe's `window.location` is +kept in sync with the host's — a fragment is _bound_ by default, sharing +location and history with the container application, and only an explicit +`src` attribute makes it _unbound_ with a location of its own — and +`BroadcastChannel` is the sanctioned channel between realms. + +That binding has a consequence for configuration. A bound fragment is +embedded by id and routed to by a gateway, so the shell never sets its URL, +and reading `location.search` inside one returns the host's query string +rather than a fragment-specific one. A shell therefore cannot hand +per-fragment configuration down on a URL; it publishes it on the top-level +window instead, which every fragment can read because they all share the +shell's origin. See README.md "Multiple tabs of the same composed page" for +the pattern, applied there to a per-tab channel name. + +A separate realm means a separate Angular injector, which means **every +realm builds its own copy of every telemetry service** — including its own +AWS RUM client. + +### Why that is a problem + +| Left alone | Consequence | +| ----------------------------- | -------------------------------------------------------------------------------------------------------------- | +| N RUM clients | Each mints its own session. One human becomes N sessions and N users; unique-user counts are wrong | +| N × `sessionEventLimit` | Separate event budgets, separate dispatches, N calls to the credentials broker, N copies of the SDK downloaded | +| Shared origin, shared cookies | N clients contend for one RUM session cookie | + +### The arrangement + +**One realm hosts; the rest forward.** + +```mermaid +flowchart LR + subgraph shell [Shell realm] + S[provideCpsTelemetryRumSink] --> R[AwsRum] + H[provideCpsTelemetryBroadcastHost] + H --> S + H --> L[CPS_LOG_API_PROVIDER] + end + subgraph f1 [Fragment realm] + A[CpsBroadcastTelemetrySink] + AL[CpsBroadcastLogApiProvider] + end + subgraph f2 [Fragment realm] + B[CpsBroadcastTelemetrySink] + BL[CpsBroadcastLogApiProvider] + end + A -- BroadcastChannel --> H + AL -- BroadcastChannel --> H + B -- BroadcastChannel --> H + BL -- BroadcastChannel --> H + R --> AWS[AWS RUM] + L --> LOGS[Log backend] +``` + +```ts +// shell +providers: [ + provideCpsTelemetry({ application: 'shell', environment: 'prod', version }), + provideCpsTelemetryRumSink(), + { provide: CPS_LOG_API_PROVIDER, useExisting: MyLogBackend }, + provideCpsTelemetryBroadcastHost() +]; + +// fragment — no AWS client, no SDK bundle, no broker call +providers: [ + provideCpsTelemetry({ application: 'cart', environment: 'prod', version }), + provideCpsTelemetrySink('broadcast') +]; +``` + +One session, one budget, one bundle. Application code inside a fragment +does not change — it injects the same services and calls the same methods; +only the sink binding differs. This is exactly what `CpsTelemetrySink` was +built as an abstraction _for_. + +Two settings have to agree across realms: **`eventNamespace`**, or the +event types diverge and CloudWatch queries fragment along with the UI, and +the **channel name**, which both `provideCpsTelemetrySink('broadcast', …)` +and `provideCpsTelemetryBroadcastHost` take as an optional argument. +`application` should differ per realm — that is what identifies which +fragment emitted what. + +The host answers an identity handshake so followers report the shell's +session id and user id on their log records, and it announces itself +unsolicited at startup so a fragment that booted first is not left waiting. +Both travel together in every `identity` message — a fragment that only +learned the session id would carry a stale user id (or the reverse) until +something else happened to trigger a full re-announce. `getSessionId()`/ +`getUserId()` in a follower return `undefined` for the one task before the +answer arrives. + +### Log records + +Logs take the same route as events. `provideCpsTelemetrySink('broadcast')` +binds `CPS_LOG_API_PROVIDER` to `CpsBroadcastLogApiProvider` along with the +sink, so a fragment needs no log backend, no credentials for one, and no +provider of its own. Each record is built and redacted in the fragment by +`CpsLoggerService`, exactly as in any realm, then posted as a `log` +message; the host hands it to _its_ `CPS_LOG_API_PROVIDER` and tells its +`CpsTelemetryMonitor`, marked as forwarded. One backend receives every +realm's records, the same way one RUM client receives every realm's events, +and the shell's diagnostics popup sees fragment logs because the shell is +what delivers them. + +The provider's other two calls cross too: + +- **`flush()`** posts `log-flush`; the host calls its provider's `flush`. +- **`query()`** is a request and an answer. The fragment posts + `log-query` with a fresh id, the leader host runs its provider's `query` + and posts `log-query-result` with the same id, and the fragment resolves + the matching promise. Every follower hears every answer and ignores ids it + did not ask. The host always answers — `[]` when it has no provider or + the provider throws or rejects — so a follower waits for its timeout + (`CPS_BROADCAST_LOG_QUERY_TIMEOUT_MS`, 10 s) only when no host is + listening at all, and resolves `[]` at once when no channel could be + opened. Records in an answer come from the host's backend, not this + library, so the fragment keeps only those that pass + `cpsIsBroadcastLogRecord`. The timeout runs outside Angular's zone, so a + query nobody answers doesn't hold the application unstable — and so + `whenStable`, hydration and `registerWhenStable` waiting — for its whole + length; the result is delivered back inside the zone. + +The host requires `CPS_LOG_API_PROVIDER`, like `CpsLoggerService` does +(§10): a shell hosting fragments without one fails at injection. Dropping +forwarded records with a console warning was the alternative, and it is +exactly the failure mode that section rejects — a composed page running +perfectly while its fragments' logs go nowhere, noticed only when someone +goes looking for them. The price is that a shell hosting fragments binds a +log provider even if it never logs itself — for a page with no logging at +all, `CpsNoopLogApiProvider`, which states that the way `'noop'` does for +the sink. + +A fragment with `mirrorErrorsToRum` sends each error-level record twice: +the record through the log provider, then its mirror through the sink — +two messages, with no id in common. The host links them for its monitor +anyway, so the shell's diagnostics popup shows the mirror against its +record as the fragment's own does: an `error` arriving straight after an +error-level record from the same realm, with the name and message the +logger mirrors for it, gets that record's sequence as `relatedSequence`. +Both are posted in the same task, so nothing can arrive between them. A +mirror that doesn't match exactly is shown unlinked, never linked wrongly. + +### A realm that both forwards and hosts + +A host receives every message on its channel from any other +`BroadcastChannel` object — including those its own realm opened. In a +realm that forwards (`provideCpsTelemetrySink('broadcast')`) the host would +receive its realm's own messages and hand them to the forwarding sink or +log provider, which posts them again: one log line becomes an endless +stream. So a host whose sink is `CpsBroadcastTelemetrySink`, or whose log +provider is `CpsBroadcastLogApiProvider`, warns once, closes its channel, +and never joins the leader election. Detecting it is exact — the classes +are the library's own — where letting it run would flood the channel from +the first message. + +### Fields that behave correctly across realms + +- **Durations.** `startedAt` is epoch milliseconds and `cpsEpochToPerf` + converts using the _local_ `timeOrigin`, so a timestamp taken in the + shell reads correctly in a fragment despite the iframe having a later + origin. The one limit: a moment from _before_ the fragment's realm + existed gets clamped away and falls back to now. +- **Page identity.** The iframe's `location` is synced to the host's, so + the X-Ray same-origin regex and the route template both resolve against + the real URL. +- **Debug flags.** Same origin means one `localStorage`: setting + `debugScenario` once turns it on in every realm, and all of them log to + the same console. +- **Correlation.** A `CpsScenario` is a class instance and cannot cross a + realm, but `scenarioId` is just a string. Broadcast it and open a child + scenario with `parentScenarioId`. +- **User Timings.** Same origin means marks and measures are written to + `top.performance` rather than the fragment's own, so every realm's + entries land on the one Timings track DevTools actually has open, not + hidden inside each iframe. `cpsMarkName` prefixes every entry with the + realm's own `application`, so several fragments landing in that one + track stay distinguishable rather than colliding. +- **`elapsed`.** Built from `top.performance.now()` (`cpsElapsedNow`), the + same reasoning as User Timings — a fragment's own `performance.now()` + runs from a later `timeOrigin` than the shell's, and `elapsed` exists + specifically so events from one session can be lined up against each + other; a per-realm value would silently defeat that. Falls back to the + local realm's own clock if `top` throws (cross-origin, sandboxed). + +### What a follower realm must not provide + +Each of these recreates in a fragment something that belongs to the shell: + +| Not in a fragment | Why | +| ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `provideCpsTelemetryRumSink()` | A second AWS client, which is exactly the situation this whole arrangement exists to prevent | +| `CPS_RUM_CREDENTIALS_PROVIDER` | Without a RUM sink there is nothing to authenticate | +| `provideCpsTelemetryBroadcastHost()` | Beside the forwarding sink it would loop, so it stays inactive with a warning ("A realm that both forwards and hosts") — wasted setup, and a sign the realm is misconfigured | +| `CPS_LOG_API_PROVIDER` | `'broadcast'` mode already binds it to the forwarding provider. Bound after that, it wins: the fragment ships its own logs, and the shell — its popup included — never sees them | + +### Fragments that also deploy standalone + +A fragment shipped both ways needs a different sink in each: forwarding +when embedded, its own client when it is the whole page. +`provideCpsTelemetrySink('broadcast')` covers forwarding; +`provideCpsTelemetryRumSink()`, from the separate `cps-telemetry/rum` entry +point, covers a standalone deployment's own client. Logs follow the same +switch: `'broadcast'` binds the forwarding log provider itself, so the +standalone branch is the only one that binds `CPS_LOG_API_PROVIDER` — bound +in both, the fragment's own would win and bypass the shell. Which one +applies is read from the deployment's own configuration. + +Runtime detection was considered and rejected. A realm cannot tell +synchronously whether a shell is listening; the identity handshake takes a +task in each direction. Detecting would mean buffering every event during a +probe window and then guessing when the window expires — and losing that +race against a slow-booting shell produces exactly the two sessions this +arrangement exists to prevent. Whether a fragment is embedded is not +something to be discovered at runtime — it is a deployment fact, and +configuration states facts exactly. + +The trade is one line of conditional configuration in the fragment. +Application code is unchanged either way. + +### Behaviour without a host + +A follower whose messages reach nobody — no shell yet, a browser with no +`BroadcastChannel`, a server-side render — degrades to a no-op sink. +Scenarios run and settle, loggers write, nothing throws; the telemetry and +the log records are simply not shipped, and start being shipped the moment +a host appears. `query()` resolves `[]`. + +That is deliberate: a fragment has to be developable and testable on its +own, and failing loudly just because the composed page it will eventually +live in is absent would be the wrong trade. It is the same fail-open posture +the RUM sink takes when the credentials broker is unreachable. + +### Multiple hosts on one channel + +`BroadcastChannel` is origin-wide, not page-local — a shell opened in two +tabs starts two independent `CpsTelemetryBroadcastHost` instances on the +same channel, each with its own injector and its own AWS RUM client. +Recording through both would double every forwarded event, so +`CpsTelemetryBroadcastHost` runs a Web Locks-based leader election +(`cpsElectBroadcastHostLeader`) in its constructor: the same lock name, +requested by every host on a channel, is granted to exactly one caller at +a time. Only the elected leader records anything or announces identity; +every other host stays fully passive until the leader is destroyed (its +tab closes) and releases the lock, at which point the next queued host +takes over. + +"Passive" describes the losing host only — not the fragments in its tab. +`CpsBroadcastTelemetrySink` doesn't know or care about the election; a +fragment in the losing tab keeps forwarding on the same origin-wide +channel regardless, and the winning host — the only one actually +processing messages — records it through its own AWS RUM client. The +event isn't lost, but it is attributed to the winning tab's session and +page, not the tab it actually came from. See "Known limits" below. + +Feature-detected and fail-open both ways: a browser without the Web Locks +API elects immediately (matching this arrangement's pre-election, single- +host behaviour), and a lock _request_ that fails — document not fully +active, a Permissions-Policy blocking Web Locks — also elects immediately +rather than leaving a host silently non-leader, and therefore permanently +inert, for the rest of the session. That covers a rejected promise and a +synchronous throw from `request()` itself alike (a `try`/`catch` around +the call, not just a `.catch()` on its result) — this runs unguarded from +`CpsTelemetryBroadcastHost`'s constructor, itself constructed eagerly +inside an `APP_INITIALIZER`, so an uncaught synchronous throw here would +crash application bootstrap rather than just fail to elect a leader. + +A host destroyed while its own request is still queued — never granted — +is also handled correctly: `cpsElectBroadcastHostLeader` tracks that a +release was requested even though there was nothing to release yet, so +when the lock is eventually granted to that (now-destroyed) request, it +resolves immediately without electing instead of holding the lock open. +Without this, the lock would never be released again — the returned +`release` closure only ever fires once, before the grant reassigns it — +permanently starving every host still queued behind it. + +**Detecting a genuine duplicate provider is separate from the election +above, deliberately.** `CpsTelemetryBroadcastHost` also keeps a +module-scoped `Map<channelName, count>` — `hostsInThisRealm` — tracking +how many of its own instances are alive **in this JS realm** at +construction/destruction time. A second host on the same channel in the +same realm (e.g. `provideCpsTelemetryBroadcastHost()` supplied in both a +root and a lazy-loaded module) warns immediately: +`a second telemetry host is active on channel "..." in this document`. +This intentionally does **not** reuse the `identity` broadcast that used +to drive this warning in an earlier version of this file (before the +leader election existed) — `BroadcastChannel` is origin-wide, so a +non-leader receiving another host's `identity` message can't tell a real +same-document duplicate apart from a different, entirely legitimate tab +of the same composed page (see "Multiple hosts on one channel" above); +reusing it would make this warning fire constantly for the ordinary +multi-tab case it must never fire for. A plain module-scoped map has no +such ambiguity, since it is realm-local by construction — a different +tab (or iframe) is a different realm and never touches it. + +### Known limits + +- **Forwarding at unload is best-effort.** `BroadcastChannel` delivers on a + later task, and a fragment torn down before that task runs may never + have its message received by the host — nothing here specifically + targets that. The general `visibilitychange → hidden` flush (§5) helps + incidentally, since it dispatches whatever the sink already holds, + forwarded messages included, but it is a page-lifecycle safety net for + every app, not a fragment-specific fix, and it cannot rescue a message + still in flight on the channel when the fragment's frame is torn down. +- **The same composed page open in two tabs can cross-attribute + telemetry.** The leader election above stops one event from being + recorded twice, but the losing tab's own fragments are still forwarding + on the same origin-wide channel — their events and log records reach the + winning host and get recorded under _its_ tab's session and page, not + their own. + Nothing here is fragment-specific to that tab; `CpsBroadcastMessage` + carries no tab/window identifier at all, so the protocol has no way to + route a message back to its originating tab even in principle. If the + same composed page could realistically be open in more than one tab, + give each tab's own instance a unique channel name instead of the + shared default, so tabs never share a channel — see README.md + "Multiple tabs of the same composed page" for the pattern. diff --git a/projects/cps-telemetry/README.md b/projects/cps-telemetry/README.md new file mode 100644 index 000000000..6e924346b --- /dev/null +++ b/projects/cps-telemetry/README.md @@ -0,0 +1,1446 @@ +# cps-telemetry + +A small, reusable Angular telemetry library. It covers three things: +**application logs**, **scenario health telemetry**, and **business/UX +events**, with an optional AWS CloudWatch RUM sink. + +Nothing in this library is tied to one specific application. The host app +supplies its own event names, configuration, and AWS credentials. + +See [DESIGN.md](./DESIGN.md) for the architecture, the AWS capability checks, +and the reasoning behind each decision. + +## Install + +```bash +npm install cps-telemetry +# optional, only if you use the AWS RUM sink +npm install aws-rum-web +``` + +## Setup + +```ts +import { + CPS_LOG_API_PROVIDER, + provideCpsTelemetry, + provideCpsTelemetrySink, + withLogging, + withScenarios +} from 'cps-telemetry'; +import { + CPS_RUM_CREDENTIALS_PROVIDER, + provideCpsTelemetryRumSink +} from 'cps-telemetry/rum'; + +providers: [ + provideCpsTelemetry( + // Identity: required, shared by every concern below. + { + application: 'my-app', + environment: 'production', + version: '1.0.0', + + // Optional. Prefixes the emitted event types — `com.cps.scenario`, + // `com.cps.scenario.step`, `com.cps.bi` by default. Set your own + // namespace when migrating an app whose CloudWatch metrics, queries or + // dashboards already key on one. + eventNamespace: 'com.my-app' + }, + + // Every concern below is optional — skip a with*() call to take the + // library default for it, the same way Angular's own + // provideHttpClient(withInterceptors(...)) composes optional behavior. + withLogging({ minLevel: 'warn' }), + withScenarios({ maxSteps: 10 }) + // withBiEvents({ ... }), withRedaction({ ... }) are also available. + ), + + // Required. Both are chosen explicitly. There is no default destination, + // so an app can never look wired up while actually shipping nothing. + // provideCpsTelemetryRumSink() (from 'cps-telemetry/rum') is the RUM sink; + // provideCpsTelemetrySink('broadcast' | 'noop') covers the other two — + // see "Two entry points" below for why RUM is split out. + provideCpsTelemetryRumSink(), + { provide: CPS_LOG_API_PROVIDER, useExisting: MyLogBackend }, + + // Optional. + { + provide: CPS_RUM_CREDENTIALS_PROVIDER, + useExisting: AppRumCredentialsProvider + } +]; +``` + +### Three entry points + +`CpsRumTelemetrySink`, `provideCpsTelemetryRumSink`, and everything +`CpsRumCredentialsProvider`-shaped live in a separate secondary entry point, +`cps-telemetry/rum`, not the main `cps-telemetry` barrel. That's so an app +using only `'broadcast'`/`'noop'` never needs `aws-rum-web` (an optional peer +dependency) resolvable at build time — importing anything from +`cps-telemetry/rum` is what opts an app into that requirement. + +The [diagnostics popup](#diagnostics-popup) lives in `cps-telemetry/diagnostics` +for the same reason: it is built from cps-ui-kit, an optional peer dependency +that the rest of the library never touches. See +[DESIGN.md §3, "Entry points"](./DESIGN.md#entry-points) for the full +reasoning. + +Each concern is independently optional. An app that only configures logging +never has to think about scenarios, BI events, or redaction. If you want to +override one concern directly through Angular's DI — in a test, or with a +value computed at runtime — each has its own token: `CPS_LOG_CONFIG`, +`CPS_SCENARIO_TELEMETRY_CONFIG`, `CPS_BI_TELEMETRY_CONFIG`, `CPS_REDACT_CONFIG`. You target that +token directly, with no need to rebuild the whole identity object. See +[DESIGN.md](./DESIGN.md) for why identity is required and shared while every +other concern gets its own token. + +`provideCpsTelemetry()` only registers configuration. If you inject +`CpsScenarioTelemetryService` or `CpsBiTelemetryService` without configuring a +sink, it fails at bootstrap with `NG0201` — the same way a missing +`provideRouter()` does. A log API provider is likewise required to inject +`CpsLoggerService` at all. For local development, or a deployment that ships +nothing, say so explicitly: + +```ts +providers: [ + provideCpsTelemetrySink('noop'), + { provide: CPS_LOG_API_PROVIDER, useClass: CpsNoopLogApiProvider } +]; +``` + +`CpsNoopLogApiProvider` is the log counterpart of `'noop'`: records are +discarded and `query()` finds none. + +`CpsLoggerService` is the one exception to the sink rule above. Its actual +destination is the log API provider, and a sink is only there to enrich it — +`sessionId`/`userId` correlation (see [Who is signed in](#who-is-signed-in)) +and the optional `mirrorErrorsToRum` mirroring (see [Logging](#logging)). If +you only want structured logging — no scenarios, no BI events, no RUM at all +— you can leave out `provideCpsTelemetrySink(...)` entirely. The logger still +works, just without that identity correlation, and `mirrorErrorsToRum` +silently does nothing instead of failing at bootstrap. + +Supply AWS details by implementing `CpsRumCredentialsProvider`: + +```ts +@Injectable({ providedIn: 'root' }) +export class AppRumCredentialsProvider implements CpsRumCredentialsProvider { + async load(): Promise<CpsRumBootstrap | null> { + // no-store: this response carries live, temporary AWS credentials. + const res = await fetch('/rum/init', { cache: 'no-store' }); + if (!res.ok) return null; + + const { enabled, config, credentials } = await res.json(); + return enabled ? { config, credentials } : null; + } +} +``` + +Returning `null` turns off shipping without disabling the library — including +from a later refresh, not just the initial load, so a provider can revoke +telemetry mid-session and the already-running client is torn down rather +than left collecting with stale credentials. Returning a bootstrap with +`credentials` omitted is a different, valid state: an app monitor configured +for unauthenticated access, not a disable signal — as long as the session was +never authenticated to begin with. The underlying AWS RUM client has no way +to clear credentials once applied, so a _later_ refresh that omits +`credentials` for a session that already had real ones doesn't downgrade it +to unauthenticated; the sink keeps using the existing credentials and retries +the refresh instead, reporting a dev-mode warning rather than going silent. + +### Advanced RUM configuration + +Almost every `aws-rum-web` option is available on `config`, each with a +sensible default if you leave it out — see `CpsRumAppMonitorConfig`'s own +JSDoc for the full, grouped list. A representative sample: + +```ts +async load(): Promise<CpsRumBootstrap | null> { + const res = await fetch('/rum/init'); + if (!res.ok) return null; + const { enabled, config, credentials } = await res.json(); + if (!enabled) return null; + + return { + config: { + ...config, + sessionEventLimit: 400, // raise the session's 200-event budget + cookieAttributes: { sameSite: 'Lax' }, + pagesToExclude: [/^\/admin/], + disableAutoPageView: true, + headers: { 'x-app-build': config.buildId } + }, + credentials + }; +} +``` + +## Scenarios + +A scenario is one user journey — load customer data, open a report, submit a +search. Any number can run at once, and each is fully independent. + +### Declaring the vocabulary + +Scenario and step names are **metric dimensions**, not free text, so they are +a finite type. Declare your application's names once in a schema file: + +```ts +// src/app/telemetry.schema.ts +declare module 'cps-telemetry' { + interface CpsScenarioNames { + 'load-customer-data': true; + } + + interface CpsScenarioSteps { + 'fetch-data': true; + render: true; + } +} + +export {}; +``` + +Import that file once — anywhere in the compilation — and every name gets +checked from then on: + +```ts +scenario.step('fetch-data'); // ok +scenario.step('fetch-dat'); // error TS2345 +``` + +A typo here would not just produce a wrong number. It would silently start a +**second, incomplete** metric series, and the dashboard meant to catch the +regression would keep reading healthy. Interpolating an id — +``step(`load-${customerId}`)`` — causes the same problem at a larger scale. +The type turns both from a data problem you find a month later into a compile +error you find right away. + +Both registries start empty, and `CpsScenarioName` / `CpsStepName` fall back +to `string` until the first augmentation — so adopting this is optional, and +you can do it one name at a time. `CpsScenarioSteps` also covers +`aggregateStart` / `aggregateEnd`, since they name a phase of work, just like +a step does. + +### Recording a journey + +```ts +const scenario = this.scenarioTelemetry.start({ + name: 'load-customer-data', + feature: 'customers' +}); + +try { + scenario.step('fetch-data'); + const rows = await this.api.fetchCustomers(); + + scenario.step('render'); // closes 'fetch-data' automatically + this.rows.set(rows); + + scenario.complete({ metadata: { rowCount: rows.length } }); +} catch (error) { + scenario.fail({ error }); +} +``` + +`complete`/`fail`/`incomplete`/`cancel` all take the same shape, +`(outcome?: CpsScenarioOutcome)`, so there is exactly one thing to remember no +matter which one you call: + +| Method | Effect | +| --------------------------------------------- | ------------------------------------------------------------------------------- | +| `step(name, metadata?)` | Opens a step, closing the previous one as completed | +| `endStep(detail?)` | Closes the open step early, when the work finishes well before the next step | +| `failStep(error, detail?)` | Closes the open step as failed; the scenario keeps running | +| `setData(metadata)` | Merges attributes into the scenario while it is still running | +| `aggregateStart(name)` / `aggregateEnd(name)` | Sums repeated calls of one operation | +| `complete(outcome?)` | Settles as `success` | +| `fail(outcome?)` | Settles as `failure` — pass the thrown value as `outcome.error` | +| `cancel(outcome?)` | Settles as `abandoned`, caused by the caller (`metadata.abandonedBy: 'caller'`) | +| `incomplete(outcome?)` | Settles as `incomplete` | +| `settle(status, outcome?, error?)` | Settles into a status you already have as data — **for adapters**, see below | +| `toRecord()` | Snapshots the current `CpsScenarioRecord`; safe to call mid-flight | + +`CpsScenarioOutcome` carries `statusCode`, `message`, `reason`, `metadata`, +and `error`. `message` and `reason` are separate fields on purpose: `message` +is a free-text note, `reason` is a short structured value you can group by +(`incomplete({ reason: 'no-results' })`). `statusCode` and `error` are kept +apart the same way. All of them end up on the record. + +### RxJS streams and `traceScenario` + +For Observable-driven journeys (an `HttpClient` request, for example), use the +pipeable `traceScenario` operator. It completes or fails the scenario +automatically based on how the stream ends — and if it's torn down some +other way (a superseding `switchMap`, `takeUntilDestroyed()`, a manual +unsubscribe) before that, it cancels the scenario instead of leaving it to +self-settle as a `timeout`. + +```ts +import { traceScenario } from 'cps-telemetry'; + +this.api + .fetchCustomers() + .pipe( + traceScenario(scenario, (rows) => ({ + metadata: { rowCount: rows.length } + })) + ) + .subscribe(); +``` + +That cancellation carries no `reason` unless you supply `cancelOutcome`. +This matters most for a `switchMap`-superseded scenario specifically: your +own `scenario.cancel({ reason: 'superseded' })` called from the newer +value's `switchMap` projector never works, because `switchMap` already +unsubscribed — and thereby settled — the prior scenario before that +projector runs. `cancelOutcome` is the only way to label it: + +```ts +this.searchSubject$.pipe( + switchMap((query) => { + const scenario = this.scenarioTelemetry.start({ name: 'search' }); + return this.api + .search(query) + .pipe( + traceScenario(scenario, { cancelOutcome: { reason: 'superseded' } }) + ); + }) +); +``` + +There are five statuses: `success`, `failure`, `abandoned`, `incomplete` and +`timeout` — there is no "in progress" value. `scenario.status` stays +`undefined` until it settles; use `scenario.isSettled` to check whether it is +done. Once settled, a scenario stays settled: calling a settle method again is +a no-op, never a throw. A scenario that never settles on its own auto-settles +as `timeout` once its deadline passes (30s by default); one still running when +the page unloads auto-settles as `abandoned`. That way journeys the user +walked away from, or that never finished, show up in the data instead of just +vanishing. + +### Choosing an outcome + +`failure` means something broke — a defect. `abandoned` means the journey +stopped mattering: the user navigated away, or the page unloaded. +`metadata.abandonedBy` records which one — `'caller'` or `'page-hidden'`. +`timeout` means the deadline passed before the journey settled; it is its own +status rather than a cause folded into `abandoned`, so you can filter or +alert on it directly. `incomplete` is an expected path that did not reach the +goal — no search results, a guard declined, a flag routed the journey +somewhere else. + +Keeping these apart is what makes the failure rate usable for alerting. + +```ts +const rows = await this.api.search(term); +if (!rows.length) { + scenario.incomplete({ reason: 'no-results' }); // not a failure + return; +} +``` + +### Settling from a status you were handed + +In application code, call `complete()` / `fail()` / `cancel()` / +`incomplete()` directly. Naming the outcome at the call site is what lets +someone find every place a journey can fail just by searching for `.fail(`. + +`settle()` exists for adapters — places where the outcome genuinely arrives +as data: + +```ts +const outcomeFor: Record<JobState, CpsScenarioStatus> = { + ok: 'success', + error: 'failure', + superseded: 'abandoned' +}; + +jobUpdates.subscribe((update) => + this.scenarios.get(update.id)?.settle(outcomeFor[update.state]) +); +``` + +Examples include a bridge settling a scenario from a message, a replay of +recorded telemetry, or a mapping from an HTTP or job status. If you find yourself +reaching for `settle()` inside a feature, the outcome was probably known all +along — use the named method instead. + +### Measuring what the user waited for + +`complete()` stops the clock the moment the JavaScript finishes. For a +journey that ends in a render, the user is often still looking at the old +screen at that exact moment. + +The library does not settle scenarios on paint for you. Waiting is a decision +for the caller to make — there is no single right answer for how long to wait, +or what to do if nothing ever paints. + +For a rough measure, two animation frames is a one-liner and needs nothing +from this library: + +```ts +scenario.step('render'); +this.rows.set(rows); +requestAnimationFrame(() => requestAnimationFrame(() => scenario.complete())); +``` + +That only proves a frame boundary passed — not that the pixels you actually +care about were drawn. To measure the real paint, mark the element with an +`elementtiming` attribute and observe `element` entries with a +`PerformanceObserver`. Two things to know before you do: pass +`buffered: true`, since the paint usually happens before the observer starts +watching, and check `PerformanceObserver.supportedEntryTypes` first — Element +Timing only works in Chromium, so most sessions will still need one of the +fallbacks above. + +If nothing ever paints — a backgrounded tab, a render that stalled — none of +this settles the scenario, and its own `defaultTimeoutMs` settles it as +`timeout`. That is the honest outcome: the user never saw the result, so the +journey did not reach its goal. + +### Repeated work + +```ts +for (const row of rows) { + scenario.aggregateStart('format-row'); + format(row); + scenario.aggregateEnd('format-row'); +} +// -> aggregates: [{ name: 'format-row', elapsed: 84, callCount: 500 }] +``` + +### Backdating + +A journey usually starts before the code measuring it runs. Record the real +starting point and pass it as `startedAt`: + +```ts +onClick() { this.clickedAt = Date.now(); } + +// later, in the async handler +scenarioTelemetry.start({ name: 'export', startedAt: this.clickedAt }); +``` + +### Reacting to outcomes + +```ts +scenarioTelemetry.settled$ + .pipe(filter((r) => r.status === 'failure')) + .subscribe((r) => this.notifications.warn(`${r.scenarioName} failed`)); +``` + +Each emission is an independent copy, not the record shipped to the sink — +mutating it in a subscriber can never change what was already (or is about +to be) sent. + +### Inspecting what's active + +Any number of scenarios can share a `name` and run at once — a name is a +metric dimension, not a lock (two tabs, two dashboard panels, two rows being +edited independently are all legitimately concurrent). If you need to know +what's already running before starting more, `CpsScenarioTelemetryService` +gives you a plain read of it: + +```ts +scenarioTelemetry.find(scenarioId); // one scenario, by id +scenarioTelemetry.findByName('load-widget'); // every active scenario with that name +scenarioTelemetry.findByNameAndId('load-widget', scenarioId); // an id lookup, asserting the name +scenarioTelemetry.getActive(); // everything in flight, in start order +``` + +This is deliberately just a read — the library doesn't decide for you +whether a second `start()` for the same name should be blocked, ignored, or +left to run alongside the first. If you know two calls really are the same +logical action (a search box re-querying, a table page reloading), track +that scenario yourself and cancel it before starting the next one — see the +`switchMap` + `cancelOutcome` pattern above. + +### Seeing scenarios in DevTools + +Set `scenario.userTimings: true` — or just turn on the `debugScenario` flag — +and every scenario and step is mirrored to `performance.mark`/`measure`. That +puts the journey on the **Performance → Timings** track, right next to paint, +layout and network. That is where you find out _why_ a step was slow, which +the aggregate numbers in CloudWatch cannot tell you. + +Entries are named `<application>:<scenario>:<boundary>:<scenarioId>`, using +the `application` name you configured as the prefix. Filtering the Timings +track by your app's own name shows just your journeys, and on a composed page +it keeps one fragment's entries apart from another's. + +## BI events + +```ts +biTelemetry.track('export_clicked', { + exportType: 'csv', + source: 'customer-table' +}); + +// optionally correlated to a journey +biTelemetry.track( + 'export_clicked', + { exportType: 'csv' }, + { + scenarioId: scenario.id + } +); +``` + +Identical events within 400ms are collapsed into one — "identical" meaning +the same name, scenario correlation, event type, feature, and metadata +content; differing in any one of those is a distinct event. + +### Declaring the vocabulary + +Event names are metric dimensions, so they are a closed vocabulary in the +same way scenario and step names are. Declare yours once: + +```ts +// src/app/telemetry.schema.ts +declare module 'cps-telemetry' { + interface CpsBiEventNames { + export_clicked: true; + theme_changed: true; + } +} +export {}; +``` + +From then on a typo is a compile error rather than a second, silently +incomplete series: + +```ts +biTelemetry.track('export_clickd'); // error TS2345 +``` + +`CpsBiEventName` falls back to `string` until the registry is augmented, so +adoption is incremental. If you wrap `track()` in your own service, type the +wrapper's parameter as `CpsBiEventName` — a plain `string` will no longer be +assignable once you declare a vocabulary. + +All BI events share a single event type, with `eventName` carried as a field +— one schema to query, one extended-metric definition. If an existing +dashboard is keyed on a specific legacy type, a single event can override it: + +```ts +biTelemetry.track( + 'click', + { source: 'toolbar' }, + { + eventType: 'com.my-app.click' + } +); +``` + +Use that only for migration — not to give every event its own type. + +## Logging + +Bind a named logger once as a field — `getLogger` is the only way in, and +the name is what `levels`, the `debugLogger` filter, `query({ logger })` +and the console prefix all target: + +```ts +private readonly logger = inject(CpsLoggerService).getLogger('checkout'); + +this.logger.log('Cache warmed'); +this.logger.warn('Falling back to defaults', { context: 'ConfigService' }); +this.logger.error('Failed to load customer data', { + error, + correlationId: scenario.id +}); +``` + +`correlationId` is how a log line joins the rest of a journey's telemetry — +pass `scenario.id` and the same id reaches +[`query({ correlationId })`](#reading-logs-back) later. Scenarios and +logging stay independent: a scenario never logs anything on its own, and +either can be used without configuring the other. + +`withLogging({ mirrorErrorsToRum: true })` also reports every +`logger.error(...)` call to the RUM sink as an error. This is off by +default, since the RUM client's error plugin already captures _unhandled_ +errors, and this would add _handled_ ones on top, competing for the same +session event budget. It mirrors `error` when the call supplies one; a +message-only call (`logger.error('Something went wrong')`, no `error`) has +nothing to normalize, so a synthetic `Error` gets built from the message text +instead. + +Logs go to a backend **you** provide, not to AWS RUM — see DESIGN.md §6. +Where records are kept, how they are authorized, and how long they live are +your decisions, so the library asks for a backend instead of assuming one: + +```ts +@Injectable({ providedIn: 'root' }) +export class MyLogBackend implements CpsLogApiProvider { + send(record: CpsLogRecord): void { + // Fire and forget: logging must never break the application. + void fetch('/api/logs', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(record), + keepalive: true // survives page unload + }).catch(() => undefined); + } + + query(filter: CpsLogQuery): Promise<CpsLogRecord[]> { + const params = new URLSearchParams(filter as Record<string, string>); + return fetch(`/api/logs?${params}`).then((r) => r.json()); + } +} +``` + +```ts +providers: [{ provide: CPS_LOG_API_PROVIDER, useExisting: MyLogBackend }]; +``` + +One binding, and that is the whole of it. `provideCpsTelemetry` wires up +everything that calls it — that part is not something you choose. + +There is no default provider, though. A default destination would let an +application ship nothing while still looking fully wired up, and you'd only +notice once you went looking for a line that should have been there. So +forgetting this binding fails on first use instead of failing silently. + +**Delivery is entirely your provider's policy.** The library does no +batching of its own — `send` is called once per record, right when it is +written. Retries, batching, backoff and authentication all live in your +implementation, where you know what your endpoint expects. + +If your endpoint is cheap to call once per line, the example above is the +whole story. If a busy session would mean too many requests, batch inside +your own provider — collect into your own queue and flush it on your own +terms: + +```ts +@Injectable({ providedIn: 'root' }) +export class MyLogBackend implements CpsLogApiProvider { + private buffer: CpsLogRecord[] = []; + + send(record: CpsLogRecord): void { + this.buffer.push(record); + if (this.buffer.length >= 25) { + this.deliver(); + } + } + + /** Called on `pagehide`, on visibilitychange going hidden, and on teardown. Must be synchronous. */ + flush(): void { + this.deliver(); + } + + private deliver(): void { + if (!this.buffer.length) { + return; + } + const records = this.buffer; + this.buffer = []; + navigator.sendBeacon('/api/logs', JSON.stringify({ records })); + } + + query(filter: CpsLogQuery): Promise<CpsLogRecord[]> { + /* … */ + } +} +``` + +The one thing you cannot reliably catch from outside a provider is the moment +the tab closes. Implement the optional `flush()` above, and the library +calls it on `pagehide`, on `visibilitychange` going hidden (mobile browsers +routinely kill a backgrounded tab without ever firing `pagehide`), and on +teardown — so a queue like the one above never loses its last, still-pending +records. Leave `flush` out if you never buffer anything; it is optional, and +the library only calls it when it is there. + +### Reading logs back + +```ts +private readonly loggers = inject(CpsLoggerService); + +const lines = await this.loggers.query({ correlationId: scenario.id }); +``` + +`query` is on the service rather than on a logger, deliberately: one journey +usually spans several loggers, so filtering by `correlationId` has to reach +across all of them. Asking a single named logger could only ever return its +own share. You never touch `CPS_LOG_API_PROVIDER` directly. Filter by `correlationId`, `logger`, `minLevel`, a time range, or +`limit`. + +It fails open, like everything else: with no backend bound, or one that +rejects, it resolves to `[]` instead of throwing into a component. Records +come back exactly as they were stored — already redacted on the way out, so +there is nothing left to strip. + +To get logs out as a **file**, just serialize whatever `query()` returns. +Pulling them out of CloudWatch instead would need credentials that can query +the entire log group, which is the wrong trade — see DESIGN.md §9. + +### Named loggers + +Declare a logger per area of the application and bind it once, instead of +repeating a label at every call: + +```ts +// src/app/telemetry.schema.ts +declare module 'cps-telemetry' { + interface CpsLoggerNames { + checkout: true; + admin: true; + } +} + +export {}; +``` + +```ts +class CheckoutService { + private readonly logger = inject(CpsLoggerService).getLogger('checkout'); + + submit() { + this.logger.log('Submitting order'); // -> { logger: 'checkout', … } + } +} +``` + +The name lands on `record.logger` and is what per-logger levels, the debug +flag, and your own backend all key off. It is separate from `context`, which +stays free text describing what the individual line is about — `logger` says +_where the record came from_, `context` says _what it is about_, and only the +first one gets routed on. + +There is no unnamed alternative: `CpsLoggerService` exposes `getLogger` and +`query`, not `log`/`warn`/`error`, so a record without a `logger` cannot be +produced. An unnamed record would be invisible to per-logger `levels`, to a +name-scoped `debugLogger` filter, and to `query({ logger })`, while looking +identical at the call site — so the API removes the option rather than +letting it be chosen by accident. + +### Sending loggers to different destinations + +The library does not route anything for you. Every record reaches your +provider carrying its `logger`, so keeping streams apart is just a switch in +the one place that already knows where things go: + +```ts +@Injectable({ providedIn: 'root' }) +export class MyLogBackend implements CpsLogApiProvider { + send(record: CpsLogRecord): void { + const endpoint = + record.logger === 'checkout' ? '/api/logs/checkout' : '/api/logs'; + + void fetch(endpoint, { + method: 'POST', + body: JSON.stringify(record), + keepalive: true + }).catch(() => undefined); + } +} +``` + +If a provider wants to batch per destination instead of sending each record +right away, it buffers into its own per-endpoint queues — the same as the +single-queue example above. Splitting streams does not change that pattern. + +### Per-logger levels + +`minLevel` is the floor for everything; `levels` overrides it by name, either +raising or lowering it: + +```ts +provideCpsTelemetry( + { application: 'shop', environment, version }, + withLogging({ minLevel: 'warn', levels: { checkout: 'log' } }) +); +``` + +## Who is signed in + +Telemetry is anonymous until you say otherwise. Attribution lives on the +sink, so one call covers both streams — logs and RUM events read the same +value from the same place, and can never drift apart: + +```ts +@Injectable({ providedIn: 'root' }) +export class AuthService { + private sink = inject(CpsTelemetrySink); + + onSignIn(user: User) { + // An opaque id — never an email, a username or an account number. + this.sink.setUserId(user.id); + } + + onSignOut() { + this.sink.setUserId(undefined); + } +} +``` + +`setUserId('')` signs out too: an empty string identifies nobody, so it is +never pinned as an id. + +Sign-out is not just cosmetic. `pinUserId` has no inverse, so the RUM sink +starts a fresh session with a fresh anonymous id — otherwise the client would +keep attributing everything to the person who just left, which matters most +on a shared device. **So the session id changes as a result**, at the cost of +one `session_start` event and a re-rolled sampling decision. + +In a fragment, this call is forwarded to the shell, so the whole composed +page agrees on who is signed in, no matter which realm made the call. + +## Micro-frontends and web fragments + +Each fragment runs in its own JavaScript realm, so each would otherwise build +its own AWS client — turning one visitor into N separate sessions, each with +its own slice of the event budget and its own copy of the SDK. + +Instead, one realm hosts and the rest forward to it over `BroadcastChannel`: + +```ts +// shell — owns the only AWS client and the only log backend +providers: [ + provideCpsTelemetry({ application: 'shell', environment: 'prod', version }), + provideCpsTelemetryRumSink(), + { provide: CPS_LOG_API_PROVIDER, useExisting: MyLogBackend }, + provideCpsTelemetryBroadcastHost() +]; + +// fragment — no AWS client, no SDK bundle, no broker call, no log backend +providers: [ + provideCpsTelemetry({ application: 'cart', environment: 'prod', version }), + provideCpsTelemetrySink('broadcast') +]; +``` + +Code inside a fragment does not change: same services, same calls. One +session, one budget, one bundle. + +Log records go the same way. `'broadcast'` mode also binds +`CPS_LOG_API_PROVIDER` to `CpsBroadcastLogApiProvider`, which posts each +record to the shell; the shell's own log provider ships it. `flush()` is +forwarded too, and `query()` is answered by the shell's provider — or +resolves `[]` if no shell answers within 10 seconds. The shell therefore +needs a log provider of its own: `provideCpsTelemetryBroadcastHost()` +without one fails at bootstrap (`NG0201`) rather than silently dropping its +fragments' logs. A composed page with no logging at all says so explicitly: + +```ts +// shell — hosts fragments, keeps no logs +providers: [ + provideCpsTelemetryRumSink(), + { provide: CPS_LOG_API_PROVIDER, useClass: CpsNoopLogApiProvider }, + provideCpsTelemetryBroadcastHost() +]; +``` + +Fragments' log records are then discarded in the shell too. A fragment that +never injects `CpsLoggerService` sends none in the first place. + +### Connecting a fragment + +Two providers, and that is the whole integration: + +```ts +bootstrapApplication(FragmentRoot, { + providers: [ + provideCpsTelemetry({ + application: 'cart', // this fragment's own name + environment: 'production', + version: packageJson.version, + eventNamespace: 'com.my-app' // must match the shell + }), + provideCpsTelemetrySink('broadcast') + ] +}); +``` + +**Settings that have to match the shell** + +| Setting | Rule | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `eventNamespace` | **Must match.** Otherwise your CloudWatch queries fragment along with your UI | +| Channel name | **Must match.** The default (`CPS_DEFAULT_BROADCAST_CHANNEL`, `'cps-telemetry'`) needs no argument; if the shell passes a custom name, pass the same one — `provideCpsTelemetrySink('broadcast', { channelName: 'my-channel' })` | +| `application` | **Should differ** per fragment. The forwarding sink stamps its realm's `application`, `environment` and `appVersion` onto every event it sends, so you can tell which fragment emitted what in the data | + +**What a fragment must not provide** + +| Do not | Why | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `provideCpsTelemetryRumSink()` | Builds a second AWS client — one visitor becomes two sessions, exactly what this arrangement is meant to avoid | +| `CPS_RUM_CREDENTIALS_PROVIDER` | Nothing in a fragment needs AWS credentials | +| `CPS_LOG_API_PROVIDER` | `'broadcast'` mode already binds it to the forwarding provider. One bound after it wins, and the fragment then ships its own logs — the shell, and its diagnostics popup, never see them | +| `provideCpsTelemetryBroadcastHost()` | Only one realm should host. Beside `'broadcast'` mode it would receive its own realm's messages and forward them again, forever — so it detects that, logs a console warning (`... so it cannot also host it; the host stays inactive ...`) and does nothing. Two hosts in one realm log `a second telemetry host is active on channel "..." in this document`; that is same-document detection only — it never fires for the _shell_ itself running in more than one tab, which is legitimate (see below) | + +The shell itself can safely be open in more than one tab without ever +double-recording one event: `BroadcastChannel` is origin-wide, so every +tab's host shares the same channel, but only one is elected leader (a Web +Locks-based election) and actually records forwarded telemetry. "Passive" +here means the losing tab's _host_ doesn't record or announce identity — +it does **not** mean that tab's own fragments are harmless. They keep +forwarding on the same origin-wide channel regardless of which tab wins, +so their events get recorded through the _winning_ tab's AWS RUM client — +attributed to a different tab's session and page than the one they +actually came from. If the same composed page could realistically be open +in more than one tab, see "Multiple tabs of the same composed page" below +to avoid that. See DESIGN.md §13 for the full mechanism. + +### Multiple tabs of the same composed page + +The origin-wide leader election above stops one broadcast event from being +recorded twice, but it does not stop a losing tab's own fragments from +being recorded under a _different_ tab's session — their events still +reach whichever tab won, since `BroadcastChannel` and the Web Locks +election are both scoped to the whole origin, not to one tab. If your +composed page could realistically be open in more than one tab at once, +give each tab's own instance a unique channel name instead of relying on +the shared default, so tabs never even share a channel: + +```ts +// shell — generate once per page load, before composing any fragment +const channelId = `cps-telemetry-${crypto.randomUUID()}`; + +providers: [ + provideCpsTelemetry({ application: 'shell', environment: 'prod', version }), + provideCpsTelemetryRumSink(), + provideCpsTelemetryBroadcastHost(channelId) +]; +``` + +`channelId` then has to reach each fragment. Publish it as a global on the +top-level window. Every fragment is same-origin with its shell — a +`BroadcastChannel` between them would not work otherwise — so `window.top` +is reachable from inside any of them, plain iframe and +[Web Fragment](https://web-fragments.dev) alike: + +```ts +declare global { + interface Window { + __cpsTelemetryChannel?: string; + } +} + +// shell — at module scope, before any fragment can read it. Browser-only, +// so the same module stays safe to evaluate under SSR. +if (typeof window !== 'undefined') { + window.__cpsTelemetryChannel = channelId; +} +``` + +Under SSR, generate the id inside that same browser check rather than at +module scope — `crypto` is only a global from Node 19, so an unguarded +`crypto.randomUUID()` can fail a server render outright. Nothing on the +server needs an id: the broadcast host no-ops there, and +`provideCpsTelemetryBroadcastHost` accepts `undefined`, simply omitting the +channel override. + +```ts +// fragment +function shellChannelName(): string | undefined { + if (typeof window === 'undefined') { + return undefined; // server render + } + try { + return window.top?.__cpsTelemetryChannel; + } catch { + return undefined; // cross-origin `top` + } +} + +providers: [ + provideCpsTelemetry({ application: 'cart', environment: 'prod', version }), + provideCpsTelemetrySink('broadcast'), + { provide: CPS_BROADCAST_CHANNEL, useFactory: shellChannelName } +]; +``` + +Bind it through `CPS_BROADCAST_CHANNEL` with `useFactory` rather than +passing `channelName` to `provideCpsTelemetrySink`: the option binds +`useValue`, read when the providers array is built, while a factory is +resolved when the sink is first constructed — late enough that a fragment +booting before the shell publishes the id still picks it up. Either way, +`undefined` falls back to the default channel, so a fragment running with no +shell behaves exactly as it does without any of this. + +Reframing does not get in the way of that read: it virtualizes the DOM and +navigation — `document` queries, `history`, `location`, `navigator`, event +retargeting — so that a fragment's DOM work lands in its shadow root. It +does not intercept `top`, `parent`, `BroadcastChannel` or storage. That last +one is the point: if it did, nothing in this section would work at all. + +**Where the shell owns the fragment's URL, a query parameter does the same +job.** That is the plain iframe case: the shell sets `src` itself, so the id +can ride on it and the fragment reads its own `location.search`. + +```ts +// shell +iframe.src = `https://fragment.example.com/?channel=${channelId}`; + +// fragment +const channelId = new URLSearchParams(window.location.search).get('channel'); +provideCpsTelemetrySink('broadcast', { channelName: channelId ?? undefined }); +``` + +**Under Web Fragments the shell does not own that URL**, so the option isn't +available there: a fragment is embedded by id +(`<web-fragment fragment-id="cart">`) and routed to by a gateway, leaving the +shell nothing to append a parameter to. Reading one inside the fragment +wouldn't help either — a fragment is _bound_ by default, sharing +`window.location` with the host, so `location.search` yields the shell's +query string rather than a fragment-specific one. + +The `window.top` global above needs neither of those things, which is why it +is the one to reach for. + +With unique channel names, each tab's leader election trivially resolves to +itself — no cross-tab contention, and no cross-tab misattribution, since a +tab's fragments are never even listening on another tab's channel. A +duplicated tab is covered too: it loads a fresh document, so the id is +regenerated. That is worth knowing if you were reaching for `sessionStorage` +to carry the id instead — Chrome copies it into a duplicated tab, so both +would end up sharing a channel. + +Note what this does and does not buy. A per-tab channel keeps each tab's +fragments recording through _that tab's_ client, with that tab's page +context. It does not give each tab its own `sessionId` — the RUM session is +a cookie (`allowCookies` defaults to `true`), so it is shared across tabs on +the origin, and deliberately so: RUM models a session as a user's, not a +tab's. + +### A fragment that also deploys standalone + +Embedded, a fragment has to forward so the composed page keeps a single +session and a single log backend. Deployed on its own, there is no shell to +forward to, so it needs its own client and its own log backend. Which one +applies is a fact about the deployment, so read it from configuration instead +of trying to detect it: + +```ts +providers: [ + provideCpsTelemetry({ + application: 'cart', + environment: environment.name, + version: packageJson.version, + eventNamespace: 'com.my-app' + }), + + ...(environment.embedded + ? // Events and log records both go to the shell. + [provideCpsTelemetrySink('broadcast')] + : [ + provideCpsTelemetryRumSink(), + { provide: CPS_LOG_API_PROVIDER, useExisting: CartLogBackend } + ]), + + // Used only when standalone; harmless when embedded. + { provide: CPS_RUM_CREDENTIALS_PROVIDER, useExisting: CartRumCredentials } +]; +``` + +Keep the log provider inside the standalone branch. `'broadcast'` mode binds +`CPS_LOG_API_PROVIDER` itself, and one listed after it wins: bound +unconditionally, the fragment would ship its own logs even when embedded, +and the shell — its diagnostics popup included — would never see them. + +| Mode | Events go to | Log records go to | Needs | +| ------------- | --------------------------------------------------------------- | ------------------------------------------ | ---------------------------------------------------- | +| `'broadcast'` | The shell's host, over `BroadcastChannel` | The shell's log provider, through its host | A shell running `provideCpsTelemetryBroadcastHost()` | +| RUM | AWS CloudWatch RUM directly, via `provideCpsTelemetryRumSink()` | The fragment's own `CPS_LOG_API_PROVIDER` | `CPS_RUM_CREDENTIALS_PROVIDER`, a log provider | +| `'noop'` | Nowhere — everything runs, nothing ships | The fragment's own `CPS_LOG_API_PROVIDER` | A log provider. Useful for local development | + +Use `provideCpsTelemetrySink('broadcast', { channelName })` if the shell uses +a custom channel. + +Application code stays identical across these modes, so nothing outside this +provider list needs to be conditional. Note that in RUM mode the fragment is +a full telemetry client on its own — its own session, its own event budget. +That is correct when it is the whole page, and exactly what you are trying +to avoid when it is not. + +Deciding this here, rather than probing at runtime, is deliberate: a fragment +cannot tell synchronously whether a shell exists. Runtime detection would mean +buffering events during a probe window and losing the race if the shell boots +slowly — which produces the two sessions this arrangement exists to prevent. + +### Continuing a shell journey + +A `CpsScenario` is a class instance and cannot cross a realm, but its id is +just a string. Send it over whatever channel already carries your app state: + +```ts +// shell publishes +new BroadcastChannel('app').postMessage({ scenarioId: checkout.id }); + +// fragment continues it +this.scenarioTelemetry.start({ + name: 'add-to-cart', + parentScenarioId: msg.scenarioId +}); +``` + +Both records then join on `parentScenarioId`. + +### What to expect + +- **`getSessionId()` is `undefined` for the first task.** A follower asks the + shell for the session id when it is constructed, and the answer arrives one + task later. Log records written in that window carry no `sessionId` — they + are still correlated by `scenarioId`. Do not assert on it right at bootstrap. +- **Paint observation does not work in a fragment.** A `PerformanceObserver` + inside a fragment watches its own hidden iframe, never the host frame where + the pixels actually appear — so it will not fire. Settle with `complete()` + instead. +- **A fragment with no shell still works.** If nothing is listening — no host + yet, or the browser has no `BroadcastChannel` at all — the sink degrades to + a no-op. Scenarios run, logs are written, nothing throws; the telemetry + just is not shipped. A fragment developed standalone will not break, and starts + reporting as soon as it is composed into a shell that hosts. + +See DESIGN.md §13 for the reasoning and the remaining limits. + +## Debugging + +Off by default, in every environment. Set from DevTools, and nothing needs a +reload: + +```js +localStorage.setItem('debugLogger', 'true'); +localStorage.setItem('debugScenario', 'true'); +localStorage.setItem('debugBI', '1'); +``` + +`'true'` and `'1'` turn everything on. `debugLogger` also accepts a +comma-separated list of logger names, which is how you switch on one noisy +area without the rest: + +```js +localStorage.setItem('debugLogger', 'checkout,cart'); +``` + +Every line is prefixed with the emitting application, then the concern — +for logs, the logger name you filtered on, and the context after it when +one is given: + +``` +[my-app][checkout] Submitting order +[my-app][checkout][CartLoader] Fetching cart (a1b2c3) +[my-app][scenario] load-customer-data success in 68ms -> com.cps.scenario +[my-app][bi] export_clicked -> com.cps.bi +``` + +The application rather than this library's own name, for the same reason +User Timing entries are named that way: in a composed page every realm logs +to the one console, so `[shell]` versus `[cart]` is the distinction actually +worth having. The second console argument is always the literal object handed +to the sink or the log transport, so what you read is what ships. + +Scenarios print one line per event actually sent, and nothing else — so a +scenario with three steps shows a single settle line by default, not a +running commentary. Starting a scenario, opening a step, and calling a +mutator after settlement all transmit nothing, and so print nothing. To see +each step as its own line, turn on `scenario.emitLifecycleEvents`, which +makes those steps real events; the console follows because it only ever +mirrors the wire. + +## Diagnostics popup + +An in-app window showing, live, every BI event, scenario event and log +record your app hands to its telemetry destinations — no DevTools, no +backend access. Add it next to your telemetry setup: + +```ts +import { provideCpsTelemetryDiagnostics } from 'cps-telemetry/diagnostics'; + +providers: [ + provideCpsTelemetry({ application: 'my-app', environment, version }), + provideCpsTelemetryDiagnostics() +]; +``` + +It is built from cps-ui-kit, so the app needs cps-ui-kit set up as for any +cps-ui-kit dialog (styles, icons, animations). It supports the light theme only, for now. + +### Opening it + +| Platform | Shortcut | +| ----------------- | -------------------------- | +| macOS | **⇧ ⌥ ⌘ 8** | +| Windows and Linux | **Ctrl + Alt + Shift + 8** | + +Four keys, so it never opens by accident. Both combinations work everywhere, +which covers external keyboards and remote desktops. The keys are matched by +physical position, so they work on any keyboard layout, and typing an AltGr +character never triggers it. + +With the popup open, the shortcut brings focus back to it from the app, and +closes it when focus is already inside. Escape closes it too. + +It works in every environment. To change the keys, turn the shortcut off, or +limit who can open it: + +```ts +provideCpsTelemetryDiagnostics({ + // Your own combination — replaces the defaults. + shortcuts: [ + { + code: 'KeyD', + ctrl: true, + alt: true, + shift: true, + label: 'Ctrl+Alt+Shift+D' + } + ], + + // Or no keyboard at all, opening it from your own UI instead: + // shortcuts: [], then inject(CpsTelemetryDiagnosticsService).open(); + + // Read once at startup. + enabled: () => inject(AuthService).isSupportStaff() +}); +``` + +### What it shows + +Three sections — BI telemetry, scenario telemetry, and logging — listing +events newest first, from the moment the popup opens. Expand a row for the +full payload as JSON. The popup is not modal: keep using the app beside it +and watch events arrive. + +Each section has its own tools, which never affect the other two: + +- **Search** matches any field's value or name. **Add filter** compares one + field — `metadata.theme`, `status`, or `steps[].name` for any item of a + list. Field suggestions come from the events that section has seen. +- **Download JSON** saves every event that section captured, whatever its + filters, with those filters recorded in the file. **Copy JSON** puts the + same JSON on the clipboard, for when a browser blocks downloads. + +Two controls at the top apply to all three sections, because they are about +capturing rather than viewing: + +- **Pause live updates** freezes the view while you read; events are still + captured. +- **Clear** empties the history; capturing continues. + +Each section keeps the latest 500 events (`maxEventsPerSection`). History is +discarded when the popup closes. + +What it does **not** show: + +- Anything before the popup opened. +- RUM's own automatic events — page views, web vitals, HTTP and JS errors — + which never pass through this library. +- Whether the server accepted an event. It shows what was handed to the + sink or log provider; RUM can still drop events, for example when it + samples a session out or reaches its event limit. + +In a composed page, the shell's popup also shows what its fragments send — +BI and scenario events and log records alike, since the shell delivers them +— each marked with `↪` and the fragment's name. + +### Privacy + +The popup shows exactly what is sent, after redaction — including session +and user ids. It adds nothing a user couldn't already see in their own +browser's network tab, and sends nothing anywhere. Avoid sharing your screen +while it is open, and treat downloads as you would any file with ids in it. +The shortcut prevents accidental opening; it is not access control — use +`enabled` for that. + +### Reading events from code + +The popup is built on `CpsTelemetryMonitor`, from the main entry point, which +anyone can observe — a test, or your own overlay: + +```ts +inject(CpsTelemetryMonitor) + .events$.pipe(filter((e) => e.kind === 'scenario')) + .subscribe((e) => console.table(e.payload)); +``` + +Each event is a deep copy of what was sent, so observers can't change it. +While nothing subscribes, it costs one check per event. + +## Privacy + +Telemetry attributes are typed +`Record<string, string | number | boolean | null>` — flat and primitive-only, +so an object full of personal data cannot be passed in by accident. + +**Page ids come from the RUM client, and they are resolved paths.** The client +patches `history.pushState` — which is how Angular navigates — and records a +page view as `location.pathname` on its own. The library does not wire up page +views at all, so there is nothing to configure here and nothing that +double-records. + +The consequence is worth knowing: the current page id is stamped onto +**every** event recorded after it, so a parameter in the path reaches all of +them. If your routes carry identifiers, record page ids yourself instead — +walk the activated snapshot for the route template, call +`CpsRumTelemetrySink.recordPageView` with it on `NavigationEnd`, and set +`disableAutoPageView: true` in the RUM configuration so the client's own page +view recording is off. Concretely, that means a broker response like this: + +```ts +// broker response +{ + config: { + applicationId: '...', + region: 'eu-west-1', + applicationVersion: '1.0.0', + disableAutoPageView: true + } +} +``` + +A scenario's `route` field wants the same kind of template. Either way, the +client still captures the full `pageUrl` — keeping identifiers out of paths +is an application concern. On top of that, the library redacts values under +sensitive keys (`password`, `token`, `authorization`, `apiKey`, …), strips URL +query strings and fragments, normalizes errors to +`{ name, message, stack? }`, and caps every size. Arbitrary objects are never +serialized. + +The key denylist only catches PII sitting under a conventionally-named key — +it cannot tell that a value under an innocuous key like `notes` happens to be +an email address. `redact.scanValuePatterns` closes part of that gap, opt-in, +off by default: + +```ts +redact: { + scanValuePatterns: ['email', 'creditCard', 'ssn']; +} +``` + +Available shapes: `'email'`, `'creditCard'` (Luhn-validated), `'ssn'`, +`'ipv4'`, `'phone'` (NANP and African countries). An +`extraValuePatterns: RegExp[]` field, shaped like `extraKeyPatterns`, covers +an application's own content patterns as regexes. + +For redaction logic no regex can express, `extraValueTransforms` takes +plain functions instead — each receives a string (already scrubbed by +everything above) and returns the value to carry forward: + +```ts +redact: { + extraValueTransforms: [(value) => value.replace(/ACC-\d+/g, '[redacted]')]; +} +``` + +A throwing transform is skipped, not fatal — logged in dev mode, the rest of +the pipeline keeps running. + +### Turning redaction off per concern + +`withLogging`/`withScenarios`/`withBiEvents` each take a `redact: boolean` +(default `true`), so you can turn off redaction for one concern without +touching the others: + +```ts +withScenarios({ redact: false }); +``` + +This only skips the _configurable_ scrubbing for that concern — +`extraKeyPatterns`, `scanValuePatterns`/`extraValuePatterns`, and URL-query +stripping. The built-in credential denylist (`password`, `token`, `secret`, +`apiKey`, …), size caps, error normalization, and `extraValueTransforms` all +keep applying regardless — those are safety guarantees, not privacy +opt-ins, so turning redaction "off" can't accidentally leak an actual +credential. + +## Testing + +The library ships **no test helpers**. Everything a test needs is already in +the main entry, and it comes down to three providers: + +```ts +TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'my-app-test', + environment: 'test', + version: '0.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useClass: RecordingLogBackend }, + { provide: CpsTelemetrySink, useClass: CpsNoopTelemetrySink } + ] +}); +``` + +Telemetry runs for real and is discarded; logs land in your test backend, +which is just two methods: + +```ts +@Injectable() +class RecordingLogBackend implements CpsLogApiProvider { + readonly records: CpsLogRecord[] = []; + + send(record: CpsLogRecord): void { + this.records.push(record); + } + query(): Promise<CpsLogRecord[]> { + return Promise.resolve(this.records); + } +} +``` + +Nothing else is needed: the library does no batching of its own, so a record +reaches the provider as soon as it is written. + +To assert on emitted telemetry, bind a sink that records instead of +discarding. `CpsTelemetrySink` is six methods, so the double is short and +stays yours to write: + +```ts +@Injectable() +class RecordingSink extends CpsTelemetrySink { + readonly events: { eventType: string; payload: object }[] = []; + + record(eventType: string, payload: object): void { + this.events.push({ eventType, payload }); + } + // recordError also takes an optional `metadata` second argument — a + // sink can drop it, as here, or use it the way the RUM sink does, to + // fold a broadcast-forwarded error's origin into its own record. + recordError(): void {} + getSessionId(): string | undefined { + return 'test-session'; + } + setUserId(): void {} + getUserId(): string | undefined { + return undefined; + } + flush(): void {} +} + +TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'my-app-test', + environment: 'test', + version: '0.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useClass: RecordingLogBackend }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] +}); + +TestBed.inject(RecordingSink).events.filter( + (e) => e.eventType === CPS_TELEMETRY_EVENT_TYPE.scenario +); +``` + +One thing worth knowing if you are testing code that uses this library under +jsdom: jsdom implements no `BroadcastChannel`, so the broadcast sink degrades +to a no-op there. That is the documented, expected behavior — not a failure. + +## Guarantees + +- **Telemetry never breaks the application.** Every entry point is wrapped + and never rethrows. In development, suppressed errors are still reported to + the console so bugs surface; in production it stays silent. +- **SSR-safe.** Every browser-touching path is a no-op on the server. +- **Fail-open.** A broker outage, expired credentials, or an SDK throw all + leave the application behaving exactly as it would with healthy telemetry. diff --git a/projects/cps-telemetry/diagnostics/ng-package.json b/projects/cps-telemetry/diagnostics/ng-package.json new file mode 100644 index 000000000..e09fb3fd0 --- /dev/null +++ b/projects/cps-telemetry/diagnostics/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-dialog/cps-diagnostics-dialog.component.html b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-dialog/cps-diagnostics-dialog.component.html new file mode 100644 index 000000000..33f9cda88 --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-dialog/cps-diagnostics-dialog.component.html @@ -0,0 +1,78 @@ +<div class="cps-diagnostics-dialog"> + <div class="cps-diagnostics-dialog__toolbar"> + <p class="cps-diagnostics-dialog__status"> + <span + class="cps-diagnostics-dialog__dot" + [class.cps-diagnostics-dialog__dot--paused]="store.paused()" + aria-hidden="true"></span> + {{ store.paused() ? 'Paused' : 'Capturing' }} since {{ since() }} · + {{ store.total() }} event{{ store.total() === 1 ? '' : 's' }} + </p> + <div class="cps-diagnostics-dialog__actions"> + <cps-switch + label="Pause live updates" + [value]="store.paused()" + (valueChanged)="setPaused($event)"> + </cps-switch> + <cps-button + type="outlined" + size="small" + icon="delete" + label="Clear" + ariaLabel="Clear captured events" + (clicked)="clear()"> + </cps-button> + </div> + </div> + + @if (store.paused() && store.newWhilePaused() > 0) { + <div class="cps-diagnostics-dialog__paused" role="status"> + <span + >{{ store.newWhilePaused() }} new event{{ + store.newWhilePaused() === 1 ? '' : 's' + }} + held back while paused.</span + > + <cps-button + type="borderless" + size="xsmall" + label="Resume" + (clicked)="setPaused(false)"> + </cps-button> + </div> + } + + @if (store.total() === 0 && !store.paused()) { + <p class="cps-diagnostics-dialog__hint"> + Capturing from now. Use the app to generate events; they appear here as + they are sent. + </p> + } + + <div class="cps-diagnostics-dialog__sections"> + @for (section of sections; track section) { + <cps-diagnostics-section + [section]="section" + [maxPayloadChars]="maxPayloadChars"> + </cps-diagnostics-section> + } + </div> + + <footer class="cps-diagnostics-dialog__footer"> + <p> + Shows what this app hands to its telemetry destinations, after redaction. + A destination can still drop an event, for example when RUM samples a + session out. RUM's own page views, errors and performance events aren't + shown. + </p> + <p> + Contains your session and user ids: avoid sharing your screen while it's + open, and share downloads only with your team. History is discarded when + this window closes. + @if (shortcut) { + Shortcut: <kbd>{{ shortcut }}</kbd + >. + } + </p> + </footer> +</div> diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-dialog/cps-diagnostics-dialog.component.scss b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-dialog/cps-diagnostics-dialog.component.scss new file mode 100644 index 000000000..2de2dae35 --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-dialog/cps-diagnostics-dialog.component.scss @@ -0,0 +1,102 @@ +:host { + display: block; + color: var(--cps-text-primary); +} + +.cps-diagnostics-dialog { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.cps-diagnostics-dialog__toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem 1rem; +} + +.cps-diagnostics-dialog__status { + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; + font-size: 0.875rem; + color: var(--cps-text-secondary); + font-variant-numeric: tabular-nums; +} + +.cps-diagnostics-dialog__dot { + width: 0.5rem; + height: 0.5rem; + border-radius: var(--cps-radius-full, 50%); + background: var(--cps-state-success); + animation: cps-diagnostics-pulse 1.6s ease-in-out infinite; + + &--paused { + background: var(--cps-state-warn); + animation: none; + } +} + +@keyframes cps-diagnostics-pulse { + 50% { + opacity: 0.35; + } +} + +@media (prefers-reduced-motion: reduce) { + .cps-diagnostics-dialog__dot { + animation: none; + } +} + +.cps-diagnostics-dialog__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.cps-diagnostics-dialog__paused { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.75rem; + border-radius: var(--cps-radius-sm); + background: var(--cps-state-warn-surface); + color: var(--cps-text-primary); + font-size: 0.8125rem; +} + +.cps-diagnostics-dialog__hint { + margin: 0; + font-size: 0.875rem; + color: var(--cps-text-secondary); +} + +.cps-diagnostics-dialog__sections { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.cps-diagnostics-dialog__footer { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding-top: 0.5rem; + border-top: 1px solid var(--cps-border-color); + font-size: 0.75rem; + color: var(--cps-text-secondary); + + p { + margin: 0; + max-width: 75ch; + } + + kbd { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } +} diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-dialog/cps-diagnostics-dialog.component.spec.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-dialog/cps-diagnostics-dialog.component.spec.ts new file mode 100644 index 000000000..0616cd055 --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-dialog/cps-diagnostics-dialog.component.spec.ts @@ -0,0 +1,343 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { + CPS_TELEMETRY_IDENTITY, + CpsTelemetryMonitor, + CpsTelemetryPublishInput +} from 'cps-telemetry'; +import { CPS_LIVE_ANNOUNCER_SERVICE } from 'cps-ui-kit'; +import * as exportUtil from '../cps-diagnostics-export/cps-diagnostics-export'; +import { + CPS_DIAGNOSTICS_FLUSH_MS, + CpsDiagnosticsStore +} from '../cps-diagnostics-store/cps-diagnostics-store'; +import { CpsDiagnosticsDialogComponent } from './cps-diagnostics-dialog.component'; + +const bi = (name: string, theme = 'dark'): CpsTelemetryPublishInput => ({ + kind: 'bi', + eventType: 'com.cps.bi', + payload: { + eventName: name, + eventTime: '2026-01-01T00:00:00.000Z', + application: 'app', + metadata: { theme } + }, + destination: 'sink', + origin: { forwarded: false } +}); + +const scenario: CpsTelemetryPublishInput = { + kind: 'scenario', + eventType: 'com.cps.scenario', + payload: { + scenarioId: 'abc', + scenarioName: 'load-dashboard', + status: 'success', + startTime: '2026-01-01T00:00:00.000Z', + delta: 42, + elapsed: 1000, + stepCount: 1, + steps: [], + application: 'app' + }, + destination: 'sink', + origin: { forwarded: false } +}; + +const warn: CpsTelemetryPublishInput = { + kind: 'log', + payload: { + timestamp: '2026-01-01T00:00:00.000Z', + level: 'warn', + message: 'Careful now', + logger: 'checkout', + application: 'app', + environment: 'test', + version: '1' + }, + destination: 'log-provider', + origin: { forwarded: false } +}; + +describe('CpsDiagnosticsDialogComponent', () => { + let fixture: ComponentFixture<CpsDiagnosticsDialogComponent>; + let monitor: CpsTelemetryMonitor; + let announce: jest.Mock; + + const el = () => fixture.nativeElement as HTMLElement; + /** The popup's own actions, as its buttons and switch call them. */ + const ui = () => + fixture.componentInstance as unknown as { + setPaused(paused: boolean): void; + clear(): void; + }; + /** A button found by the start of its accessible name. */ + const button = (name: string) => { + const found = el().querySelector<HTMLButtonElement>( + `button[aria-label^="${name}"]` + ); + if (!found) { + throw new Error(`No button named "${name}…"`); + } + return found; + }; + const text = () => el().textContent?.replace(/\s+/g, ' ') ?? ''; + const titles = () => + Array.from( + el().querySelectorAll('[data-testid="cps-expansion-panel-title"]') + ).map((t) => t.textContent?.trim()); + const bodyRows = () => + el().querySelectorAll('td.cps-diagnostics-section__time').length; + + function settle(): void { + jest.advanceTimersByTime(CPS_DIAGNOSTICS_FLUSH_MS); + fixture.detectChanges(); + } + + beforeEach(async () => { + jest.useFakeTimers(); + announce = jest.fn(); + await TestBed.configureTestingModule({ + imports: [CpsDiagnosticsDialogComponent, NoopAnimationsModule], + providers: [ + { + provide: CPS_TELEMETRY_IDENTITY, + useValue: { + application: 'app', + environment: 'test', + version: '1.0.0' + } + }, + { + provide: CPS_LIVE_ANNOUNCER_SERVICE, + useValue: { announce, clear: jest.fn() } + } + ] + }).compileComponents(); + monitor = TestBed.inject(CpsTelemetryMonitor); + fixture = TestBed.createComponent(CpsDiagnosticsDialogComponent); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('should open empty, with three sections and an explanation', () => { + expect(titles()).toEqual([ + 'BI telemetry · no events yet', + 'Scenario telemetry · no events yet', + 'Logging · no events yet' + ]); + expect(text()).toContain('Capturing from now.'); + expect(text()).toContain('No BI events since this window opened.'); + }); + + it('should show events live, in the right section, with the right columns', () => { + monitor.publish(bi('theme_changed')); + monitor.publish(scenario); + monitor.publish(warn); + settle(); + + expect(bodyRows()).toBe(3); + expect(titles()[0]).toMatch(/^BI telemetry · 1 · last \d\d:\d\d:\d\d$/); + expect(text()).toContain('theme_changed'); + expect(text()).toContain('theme=dark'); + expect(text()).toContain('load-dashboard'); + expect(text()).toContain('42 ms'); + expect(text()).toContain('Careful now'); + expect(text()).toContain('3 events'); + }); + + it('should mark the newest rows as new', () => { + monitor.publish(bi('a')); + settle(); + expect( + el().querySelector('td.cps-diagnostics-section__time cps-chip') + ).not.toBeNull(); + }); + + it('should show the full payload when a row is expanded', () => { + monitor.publish(bi('theme_changed')); + settle(); + + ( + el().querySelector('button[aria-label="Expand row"]') as HTMLElement + ).click(); + fixture.detectChanges(); + + const pre = el().querySelector('pre'); + expect(pre?.getAttribute('aria-label')).toMatch(/^Payload of event #\d+$/); + expect(JSON.parse(pre?.textContent ?? '')).toMatchObject({ + eventName: 'theme_changed', + metadata: { theme: 'dark' } + }); + expect(text()).toContain('telemetry sink'); + }); + + it('should filter one section without touching the others', () => { + monitor.publish(bi('theme_changed', 'dark')); + monitor.publish(bi('sidebar_toggled', 'light')); + monitor.publish(warn); + settle(); + + const search = el().querySelector( + 'input[aria-label="Search BI telemetry events"]' + ) as HTMLInputElement; + search.value = 'dark'; + search.dispatchEvent(new Event('input')); + jest.advanceTimersByTime(200); + fixture.detectChanges(); + + expect(titles()[0]).toMatch(/^BI telemetry · 1 of 2/); + // Logging has its own filters, still empty. + expect(titles()[2]).toMatch(/^Logging · 1 · /); + expect(text()).toContain('Careful now'); + }); + + it('should give each section its own search field', () => { + const labels = Array.from( + el().querySelectorAll('.cps-diagnostics-search input') + ).map((i) => i.getAttribute('aria-label')); + expect(labels).toEqual([ + 'Search BI telemetry events', + 'Search Scenario telemetry events', + 'Search Logging events' + ]); + }); + + it('should clear history and keep capturing', () => { + monitor.publish(bi('a')); + settle(); + + Array.from(el().querySelectorAll('button')) + .find((b) => b.textContent?.trim() === 'Clear') + ?.click(); + fixture.detectChanges(); + expect(bodyRows()).toBe(0); + expect(announce).toHaveBeenCalledWith( + 'History cleared. Capturing continues.', + 'polite' + ); + + monitor.publish(bi('b')); + settle(); + expect(bodyRows()).toBe(1); + }); + + it('should hold events back while paused, then show them on resume', () => { + monitor.publish(bi('a')); + settle(); + + ui().setPaused(true); + monitor.publish(bi('b')); + settle(); + expect(bodyRows()).toBe(1); + expect(text()).toContain('1 new event held back while paused.'); + + ui().setPaused(false); + fixture.detectChanges(); + expect(bodyRows()).toBe(2); + }); + + it("should download a section's own events, whatever its filters", () => { + const download = jest + .spyOn(exportUtil, 'cpsDownloadJson') + .mockImplementation(() => undefined); + monitor.publish(bi('a')); + monitor.publish(bi('b')); + monitor.publish(warn); + settle(); + // A BI filter hiding one event must not remove it from BI's download. + fixture.debugElement.injector + .get(CpsDiagnosticsStore) + .setText('bi', 'nothing matches this'); + fixture.detectChanges(); + + button('Download BI telemetry').click(); + + expect(download).toHaveBeenCalledTimes(1); + const [, filename, json] = download.mock.calls[0]; + expect(filename).toMatch( + /^cps-telemetry-diagnostics-app-bi-\d{8}-\d{6}\.json$/ + ); + const doc = JSON.parse(json); + expect(doc).toMatchObject({ + format: 'cps-telemetry-diagnostics', + section: 'bi', + app: { application: 'app' }, + count: { captured: 2, droppedOldest: 0 }, + activeFilters: { text: 'nothing matches this' } + }); + expect(doc.events.map((e: { kind: string }) => e.kind)).toEqual([ + 'bi', + 'bi' + ]); + + button('Download Logging').click(); + const logging = JSON.parse(download.mock.calls[1][2]); + expect(logging.section).toBe('logging'); + expect(logging.events.map((e: { kind: string }) => e.kind)).toEqual([ + 'log' + ]); + }); + + it("should copy a section's own events as JSON", async () => { + const writeText = jest.fn((_text: string) => Promise.resolve()); + Object.assign(navigator, { clipboard: { writeText } }); + monitor.publish(bi('a')); + monitor.publish(warn); + settle(); + + button('Copy Logging').click(); + await Promise.resolve(); + + const doc = JSON.parse(writeText.mock.calls[0][0]); + expect(doc.section).toBe('logging'); + expect(doc.events).toHaveLength(1); + }); + + it('should announce new arrivals politely, at most every ten seconds', () => { + monitor.publish(bi('a')); + monitor.publish(bi('b')); + monitor.publish(warn); + settle(); + expect(announce).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(10_000); + expect(announce).toHaveBeenCalledWith( + '3 new events: 2 BI, 1 log.', + 'polite' + ); + + announce.mockClear(); + jest.advanceTimersByTime(10_000); + expect(announce).not.toHaveBeenCalled(); // nothing new + }); + + it('should produce no telemetry of its own while being used', () => { + const seen: unknown[] = []; + // Subscribe as a second observer, alongside the popup's own store. + monitor.events$.subscribe((e) => seen.push(e)); + + ui().clear(); + ui().setPaused(true); + ui().setPaused(false); + jest + .spyOn(exportUtil, 'cpsDownloadJson') + .mockImplementation(() => undefined); + button('Download BI telemetry').click(); + button('Download Logging').click(); + settle(); + + expect(seen).toHaveLength(0); + }); + + it('should stop observing when closed', () => { + expect(monitor.observed).toBe(true); + fixture.destroy(); + expect(monitor.observed).toBe(false); + }); +}); diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-dialog/cps-diagnostics-dialog.component.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-dialog/cps-diagnostics-dialog.component.ts new file mode 100644 index 000000000..894a25e59 --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-dialog/cps-diagnostics-dialog.component.ts @@ -0,0 +1,154 @@ +import { DOCUMENT } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + ElementRef, + inject +} from '@angular/core'; +import { + CPS_LIVE_ANNOUNCER_SERVICE, + CpsButtonComponent, + CpsSwitchComponent +} from 'cps-ui-kit'; +import { CPS_TELEMETRY_DIAGNOSTICS_CONFIG } from '../cps-diagnostics.models/cps-diagnostics.models'; +import { + CPS_DEFAULT_DIAGNOSTICS_CONFIG, + CPS_DIAGNOSTICS_SECTIONS, + CpsDiagnosticsStore +} from '../cps-diagnostics-store/cps-diagnostics-store'; +import { CpsDiagnosticsSectionComponent } from '../cps-diagnostics-section/cps-diagnostics-section.component'; +import { + CPS_DIAGNOSTICS_SECTION_NOUN, + CpsDiagnosticsExporter +} from '../cps-diagnostics-export/cps-diagnostics-exporter'; +import { cpsDiagnosticsTime } from '../cps-diagnostics-section/cps-diagnostics-columns'; +import { + CPS_DEFAULT_DIAGNOSTICS_SHORTCUTS, + cpsIsApplePlatform, + cpsShortcutLabel +} from '../cps-diagnostics-shortcut/cps-diagnostics-shortcut'; + +/** Screen readers hear about new events at most this often, in ms. */ +const ANNOUNCE_EVERY_MS = 10_000; + +/** + * The diagnostics popup's content, opened by + * {@link CpsTelemetryDiagnosticsService} inside a non-modal cps-dialog. + * Capturing is controlled here — pause and clear apply to everything — + * while each section filters, downloads and copies its own events. + * + * Owns its {@link CpsDiagnosticsStore}, so the captured history lives + * exactly as long as this window: it starts empty and is discarded on + * close. Uses no telemetry service itself, so opening or using it never + * produces events of its own. + */ +@Component({ + selector: 'cps-diagnostics-dialog', + imports: [ + CpsButtonComponent, + CpsSwitchComponent, + CpsDiagnosticsSectionComponent + ], + providers: [CpsDiagnosticsStore, CpsDiagnosticsExporter], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './cps-diagnostics-dialog.component.html', + styleUrl: './cps-diagnostics-dialog.component.scss' +}) +export class CpsDiagnosticsDialogComponent { + protected readonly store = inject(CpsDiagnosticsStore); + + private readonly document = inject(DOCUMENT); + private readonly host: ElementRef<HTMLElement> = inject(ElementRef); + private readonly announcer = inject(CPS_LIVE_ANNOUNCER_SERVICE, { + optional: true + }); + + private readonly config = { + ...CPS_DEFAULT_DIAGNOSTICS_CONFIG, + shortcuts: CPS_DEFAULT_DIAGNOSTICS_SHORTCUTS, + ...inject(CPS_TELEMETRY_DIAGNOSTICS_CONFIG, { optional: true }) + }; + + protected readonly sections = CPS_DIAGNOSTICS_SECTIONS; + protected readonly maxPayloadChars = this.config.maxPayloadCharsInView; + + protected readonly since = computed(() => + cpsDiagnosticsTime(this.store.startedAt()).slice(0, 8) + ); + + protected readonly shortcut = cpsShortcutLabel( + this.config.shortcuts, + cpsIsApplePlatform(this.document.defaultView?.navigator) + ); + + private lastAnnounced = { bi: 0, scenario: 0, logging: 0 }; + + constructor() { + const timer = setInterval(() => this.announceNew(), ANNOUNCE_EVERY_MS); + inject(DestroyRef).onDestroy(() => clearInterval(timer)); + } + + /** Whether keyboard focus is anywhere in the popup, header included. */ + containsFocus(): boolean { + const dialog = this.dialogElement(); + return !!dialog && dialog.contains(this.document.activeElement); + } + + /** Moves focus to the search field. */ + focusSearch(): void { + const input = this.host.nativeElement.querySelector<HTMLInputElement>( + '.cps-diagnostics-search input' + ); + (input ?? this.dialogElement())?.focus(); + } + + protected setPaused(paused: boolean): void { + this.store.setPaused(paused); + this.announce( + paused + ? 'Live updates paused. Events are still captured.' + : 'Live updates resumed.' + ); + } + + protected clear(): void { + this.store.clear(); + this.lastAnnounced = { bi: 0, scenario: 0, logging: 0 }; + this.announce('History cleared. Capturing continues.'); + } + + /** One polite summary of what arrived since the last one, if anything. */ + private announceNew(): void { + if (this.store.paused()) { + return; + } + const received = this.store.received(); + const parts = CPS_DIAGNOSTICS_SECTIONS.map((id) => ({ + id, + n: received[id] - this.lastAnnounced[id] + })).filter((p) => p.n > 0); + this.lastAnnounced = { ...received }; + if (parts.length === 0) { + return; + } + const total = parts.reduce((sum, p) => sum + p.n, 0); + const detail = parts + .map((p) => `${p.n} ${CPS_DIAGNOSTICS_SECTION_NOUN[p.id]}`) + .join(', '); + this.announce(`${total} new event${total === 1 ? '' : 's'}: ${detail}.`); + } + + private announce(message: string): void { + try { + this.announcer?.announce(message, 'polite'); + } catch { + // Announcements are a courtesy; never let one break the popup. + } + } + + private dialogElement(): HTMLElement | null { + return this.host.nativeElement.closest<HTMLElement>('[role="dialog"]'); + } +} diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-export/cps-diagnostics-export.spec.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-export/cps-diagnostics-export.spec.ts new file mode 100644 index 000000000..4761dda0e --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-export/cps-diagnostics-export.spec.ts @@ -0,0 +1,138 @@ +import { CpsTelemetryObservedEvent } from 'cps-telemetry'; +import { + cpsBuildDiagnosticsExport, + cpsDiagnosticsFilename, + cpsDownloadJson, + cpsSafeStringify +} from './cps-diagnostics-export'; + +function bi(sequence: number): CpsTelemetryObservedEvent { + return { + kind: 'bi', + eventType: 'com.cps.bi', + sequence, + capturedAt: '2026-01-01T10:00:00.000Z', + destination: 'sink', + origin: { forwarded: false }, + payload: { + eventName: `e${sequence}`, + eventTime: '2026-01-01T10:00:00.000Z', + application: 'app', + metadata: { nested: 'kept' } + } + }; +} + +const context = { + section: 'bi' as const, + startedAt: '2026-01-01T09:00:00.000Z', + paused: false, + app: { + application: 'app', + environment: 'test', + version: '1.0.0', + eventNamespace: 'com.cps' + }, + session: { sessionId: 's-1' }, + userAgent: 'jest', + activeFilters: { + text: 'e2', + fields: [ + { id: '1', path: 'kind', operator: 'equals' as const, value: 'bi' } + ] + }, + count: { captured: 3, droppedOldest: 0 }, + now: new Date('2026-01-01T10:05:00.000Z') +}; + +describe('cpsBuildDiagnosticsExport', () => { + it("should contain every one of the section's events in hand-off order, whatever the filters", () => { + const doc = cpsBuildDiagnosticsExport([bi(3), bi(1), bi(2)], context); + + expect(doc.events.map((e) => e.sequence)).toEqual([1, 2, 3]); + // Filters are recorded for context, not applied. + expect(doc.activeFilters.text).toBe('e2'); + }); + + it('should carry the format, capture window and app identity', () => { + const doc = cpsBuildDiagnosticsExport([bi(1)], context); + + expect(doc).toMatchObject({ + format: 'cps-telemetry-diagnostics', + formatVersion: 1, + section: 'bi', + exportedAt: '2026-01-01T10:05:00.000Z', + capture: { + startedAt: '2026-01-01T09:00:00.000Z', + endedAt: '2026-01-01T10:05:00.000Z', + paused: false + }, + app: { application: 'app', eventNamespace: 'com.cps' }, + session: { sessionId: 's-1' }, + count: { captured: 3, droppedOldest: 0 } + }); + }); + + it('should round-trip as valid JSON with nested payloads intact', () => { + const json = cpsSafeStringify( + cpsBuildDiagnosticsExport([bi(1)], context), + 2 + ); + const parsed = JSON.parse(json); + expect(parsed.events[0].payload.metadata.nested).toBe('kept'); + }); +}); + +describe('cpsSafeStringify', () => { + it('should replace a cycle instead of throwing', () => { + const value: Record<string, unknown> = { a: 1 }; + value.self = value; + expect(JSON.parse(cpsSafeStringify(value))).toEqual({ + a: 1, + self: '[Circular]' + }); + }); + + it('should keep an object that is shared, but not circular', () => { + const shared = { x: 1 }; + expect(JSON.parse(cpsSafeStringify({ a: shared, b: shared }))).toEqual({ + a: { x: 1 }, + b: { x: 1 } + }); + }); + + it('should write a bigint as text', () => { + expect(cpsSafeStringify({ n: BigInt(12) })).toBe('{"n":"12"}'); + }); +}); + +describe('cpsDiagnosticsFilename', () => { + it('should name the app, section and time, and nothing identifying', () => { + expect( + cpsDiagnosticsFilename( + 'my app/x', + 'logging', + new Date(2026, 0, 2, 3, 4, 5) + ) + ).toBe('cps-telemetry-diagnostics-my-app-x-logging-20260102-030405.json'); + }); +}); + +describe('cpsDownloadJson', () => { + it('should click a temporary link to a JSON blob, then remove it', () => { + const created = jest.fn(() => 'blob:x'); + Object.assign(URL, { + createObjectURL: created, + revokeObjectURL: jest.fn() + }); + const click = jest + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(() => undefined); + + cpsDownloadJson(document, 'f.json', '{}'); + + expect(created).toHaveBeenCalledWith(expect.any(Blob)); + expect(click).toHaveBeenCalledTimes(1); + expect(document.querySelector('a[download]')).toBeNull(); + }); +}); diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-export/cps-diagnostics-export.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-export/cps-diagnostics-export.ts new file mode 100644 index 000000000..20ae3ac1f --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-export/cps-diagnostics-export.ts @@ -0,0 +1,119 @@ +import { CpsTelemetryObservedEvent } from 'cps-telemetry'; +import { + CpsDiagnosticsExport, + CpsDiagnosticsFilterState, + CpsDiagnosticsSectionId +} from '../cps-diagnostics.models/cps-diagnostics.models'; + +/** What the export needs to know beyond the events themselves. */ +export interface CpsDiagnosticsExportContext { + section: CpsDiagnosticsSectionId; + startedAt: string; + paused: boolean; + app: CpsDiagnosticsExport['app']; + session: CpsDiagnosticsExport['session']; + userAgent: string; + activeFilters: CpsDiagnosticsFilterState; + count: CpsDiagnosticsExport['count']; + now?: Date; +} + +/** + * One section's downloadable document: every event captured in it, in the + * order they were handed over, regardless of the filters on screen. + */ +export function cpsBuildDiagnosticsExport( + events: readonly CpsTelemetryObservedEvent[], + context: CpsDiagnosticsExportContext +): CpsDiagnosticsExport { + const now = (context.now ?? new Date()).toISOString(); + return { + format: 'cps-telemetry-diagnostics', + formatVersion: 1, + section: context.section, + exportedAt: now, + capture: { + startedAt: context.startedAt, + endedAt: now, + paused: context.paused + }, + app: context.app, + session: context.session, + userAgent: context.userAgent, + activeFilters: context.activeFilters, + count: context.count, + events: [...events].sort((a, b) => a.sequence - b.sequence) + }; +} + +/** + * `JSON.stringify` that never throws on a payload: a repeated reference + * becomes `"[Circular]"`, and a `bigint` becomes its decimal text. + */ +export function cpsSafeStringify(value: unknown, indent?: number): string { + const ancestors: object[] = []; + return JSON.stringify( + value, + function (this: unknown, _key: string, v: unknown) { + if (typeof v === 'bigint') { + return v.toString(); + } + if (v === null || typeof v !== 'object') { + return v; + } + // `this` is the object holding v: unwind to it, then check for a loop. + while (ancestors.length && ancestors[ancestors.length - 1] !== this) { + ancestors.pop(); + } + if (ancestors.includes(v)) { + return '[Circular]'; + } + ancestors.push(v); + return v; + }, + indent + ); +} + +/** + * `cps-telemetry-diagnostics-<application>-<section>-<yyyyMMdd-HHmmss>.json` + * — no session or user id, since the name ends up in folders and tickets. + */ +export function cpsDiagnosticsFilename( + application: string, + section: CpsDiagnosticsSectionId, + at: Date +): string { + const pad = (n: number) => String(n).padStart(2, '0'); + const stamp = + `${at.getFullYear()}${pad(at.getMonth() + 1)}${pad(at.getDate())}-` + + `${pad(at.getHours())}${pad(at.getMinutes())}${pad(at.getSeconds())}`; + const app = application.replace(/[^a-z0-9._-]+/gi, '-') || 'app'; + return `cps-telemetry-diagnostics-${app}-${section}-${stamp}.json`; +} + +/** + * Saves `json` as a file through a temporary link. The browser may still + * block it without telling the page, so the popup also offers copying. + */ +export function cpsDownloadJson( + doc: Document, + filename: string, + json: string +): void { + const url = URL.createObjectURL( + new Blob([json], { type: 'application/json' }) + ); + const link = doc.createElement('a'); + link.href = url; + link.download = filename; + link.style.display = 'none'; + doc.body.appendChild(link); + try { + link.click(); + } finally { + link.remove(); + // Revoked after the click has been handed to the browser. + setTimeout(() => URL.revokeObjectURL(url), 0); + } +} diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-export/cps-diagnostics-exporter.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-export/cps-diagnostics-exporter.ts new file mode 100644 index 000000000..db0028a25 --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-export/cps-diagnostics-exporter.ts @@ -0,0 +1,137 @@ +import { DOCUMENT } from '@angular/common'; +import { inject, Injectable } from '@angular/core'; +import { + CPS_DEFAULT_EVENT_NAMESPACE, + CPS_TELEMETRY_IDENTITY, + CpsTelemetrySink +} from 'cps-telemetry'; +import { CPS_LIVE_ANNOUNCER_SERVICE, CpsNotificationService } from 'cps-ui-kit'; +import { CpsDiagnosticsSectionId } from '../cps-diagnostics.models/cps-diagnostics.models'; +import { CpsDiagnosticsStore } from '../cps-diagnostics-store/cps-diagnostics-store'; +import { + cpsBuildDiagnosticsExport, + cpsDiagnosticsFilename, + cpsDownloadJson, + cpsSafeStringify +} from './cps-diagnostics-export'; + +/** What each section calls its events, when telling people about them. */ +export const CPS_DIAGNOSTICS_SECTION_NOUN: Record< + CpsDiagnosticsSectionId, + string +> = { bi: 'BI', scenario: 'scenario', logging: 'log' }; + +/** + * Downloads or copies one section's captured events as JSON. Provided by + * the popup, beside its {@link CpsDiagnosticsStore}. + */ +@Injectable() +export class CpsDiagnosticsExporter { + private readonly store = inject(CpsDiagnosticsStore); + private readonly identity = inject(CPS_TELEMETRY_IDENTITY, { + optional: true + }); + + private readonly sink = inject(CpsTelemetrySink, { optional: true }); + private readonly document = inject(DOCUMENT); + private readonly announcer = inject(CPS_LIVE_ANNOUNCER_SERVICE, { + optional: true + }); + + private readonly notifications = inject(CpsNotificationService, { + optional: true + }); + + /** Saves the section's events as a JSON file. */ + download(section: CpsDiagnosticsSectionId): void { + try { + const json = this.json(section); + cpsDownloadJson( + this.document, + cpsDiagnosticsFilename( + this.identity?.application ?? 'app', + section, + new Date() + ), + json + ); + this.announce( + `Downloaded ${this.store.sectionCount(section).captured} ${ + CPS_DIAGNOSTICS_SECTION_NOUN[section] + } events.` + ); + } catch (error) { + this.fail("Couldn't create the download", error); + } + } + + /** + * Puts the section's events on the clipboard, for when a browser blocks + * downloads. + * + * @returns whether copying worked + */ + async copy(section: CpsDiagnosticsSectionId): Promise<boolean> { + try { + await navigator.clipboard.writeText(this.json(section)); + this.announce( + `${CPS_DIAGNOSTICS_SECTION_NOUN[section]} events copied as JSON.` + ); + return true; + } catch (error) { + this.fail("Couldn't copy to the clipboard", error); + return false; + } + } + + /** The section's export document, serialized. */ + json(section: CpsDiagnosticsSectionId): string { + return cpsSafeStringify( + cpsBuildDiagnosticsExport(this.store.sectionEvents(section), { + section, + startedAt: this.store.startedAt(), + paused: this.store.paused(), + app: { + application: this.identity?.application ?? '', + environment: this.identity?.environment ?? '', + version: this.identity?.version ?? '', + eventNamespace: + this.identity?.eventNamespace ?? CPS_DEFAULT_EVENT_NAMESPACE + }, + session: this.session(), + userAgent: this.document.defaultView?.navigator.userAgent ?? '', + activeFilters: this.store.filters()[section], + count: this.store.sectionCount(section) + }), + 2 + ); + } + + private session(): { sessionId?: string; userId?: string } { + try { + return { + sessionId: this.sink?.getSessionId(), + userId: this.sink?.getUserId() + }; + } catch { + return {}; + } + } + + private announce(message: string): void { + try { + this.announcer?.announce(message, 'polite'); + } catch { + // Announcements are a courtesy; never let one break the popup. + } + } + + private fail(message: string, error: unknown): void { + const detail = error instanceof Error ? error.message : String(error); + try { + this.notifications?.error(message, detail); + } catch { + // Nothing more to do; the popup keeps working. + } + } +} diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-filter-bar/cps-diagnostics-filter-bar.component.html b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-filter-bar/cps-diagnostics-filter-bar.component.html new file mode 100644 index 000000000..109180b82 --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-filter-bar/cps-diagnostics-filter-bar.component.html @@ -0,0 +1,97 @@ +<div class="cps-diagnostics-filter-bar"> + <div class="cps-diagnostics-filter-bar__row"> + <cps-input + class="cps-diagnostics-filter-bar__search cps-diagnostics-search" + [ariaLabel]="'Search ' + sectionName() + ' events'" + placeholder="Search all fields" + prefixIcon="search" + [clearable]="true" + [hideDetails]="true" + [value]="filters().text" + (valueChanged)="onSearch($event)"> + </cps-input> + <cps-button + type="outlined" + size="small" + icon="filter-funnel" + label="Add filter" + [ariaLabel]="'Add a filter to ' + sectionName()" + [ariaExpanded]="builderOpen()" + (clicked)="builderOpen.set(!builderOpen())"> + </cps-button> + <cps-info-circle + tooltipText="Search matches any field's value or name, ignoring case. A filter compares one field, such as metadata.theme. Use steps[].name to match any item of a list, or steps[2].name for one. All filters must match. Filters apply to this section only; its download always contains everything it captured."> + </cps-info-circle> + </div> + + @if (builderOpen()) { + <div + class="cps-diagnostics-filter-bar__builder" + role="group" + [attr.aria-label]="'New filter for ' + sectionName()" + (keydown.enter)="add()"> + <cps-autocomplete + class="cps-diagnostics-filter-bar__path" + label="Field" + placeholder="Choose a field" + [options]="pathOptions()" + [returnObject]="false" + [hideDetails]="true" + [value]="path()" + (valueChanged)="onPath($event)"> + </cps-autocomplete> + <cps-select + class="cps-diagnostics-filter-bar__operator" + label="Condition" + [options]="operators" + [returnObject]="false" + [hideDetails]="true" + [value]="operator()" + (valueChanged)="onOperator($event)"> + </cps-select> + @if (needsValue()) { + <cps-input + class="cps-diagnostics-filter-bar__value" + label="Value" + placeholder="Text to compare" + [hideDetails]="true" + [value]="value()" + (valueChanged)="value.set($event ?? '')"> + </cps-input> + } + <cps-button + size="small" + label="Add" + [disabled]="!canAdd()" + (clicked)="add()"> + </cps-button> + </div> + } + + @if (chips().length > 0 || anyActive()) { + <div class="cps-diagnostics-filter-bar__active"> + <span class="cps-diagnostics-filter-bar__label">Filters:</span> + <ul + class="cps-diagnostics-filter-bar__chips" + [attr.aria-label]="'Active filters for ' + sectionName()"> + @for (chip of chips(); track chip.id) { + <li> + <cps-chip + [label]="chip.text" + [closable]="true" + [closeButtonAriaLabel]="'Remove filter ' + chip.text" + (closed)="store.removeFilter(section(), chip.id)"> + </cps-chip> + </li> + } + </ul> + <cps-button + type="borderless" + size="xsmall" + label="Clear filters" + [ariaLabel]="'Clear filters for ' + sectionName()" + (clicked)="clearAll()"> + </cps-button> + </div> + } +</div> diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-filter-bar/cps-diagnostics-filter-bar.component.scss b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-filter-bar/cps-diagnostics-filter-bar.component.scss new file mode 100644 index 000000000..f62fbcf2d --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-filter-bar/cps-diagnostics-filter-bar.component.scss @@ -0,0 +1,52 @@ +:host { + display: block; +} + +.cps-diagnostics-filter-bar { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.cps-diagnostics-filter-bar__row, +.cps-diagnostics-filter-bar__builder, +.cps-diagnostics-filter-bar__active { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 0.5rem; +} + +.cps-diagnostics-filter-bar__active { + align-items: center; +} + +.cps-diagnostics-filter-bar__search { + flex: 1 1 18rem; +} + +.cps-diagnostics-filter-bar__path { + flex: 2 1 14rem; +} + +.cps-diagnostics-filter-bar__operator { + flex: 1 1 9rem; +} + +.cps-diagnostics-filter-bar__value { + flex: 2 1 12rem; +} + +.cps-diagnostics-filter-bar__label { + font-size: 0.8125rem; + color: var(--cps-text-secondary); +} + +.cps-diagnostics-filter-bar__chips { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin: 0; + padding: 0; + list-style: none; +} diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-filter-bar/cps-diagnostics-filter-bar.component.spec.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-filter-bar/cps-diagnostics-filter-bar.component.spec.ts new file mode 100644 index 000000000..8017f8559 --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-filter-bar/cps-diagnostics-filter-bar.component.spec.ts @@ -0,0 +1,98 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { CpsDiagnosticsStore } from '../cps-diagnostics-store/cps-diagnostics-store'; +import { CpsDiagnosticsFilterBarComponent } from './cps-diagnostics-filter-bar.component'; + +describe('CpsDiagnosticsFilterBarComponent', () => { + let fixture: ComponentFixture<CpsDiagnosticsFilterBarComponent>; + let store: CpsDiagnosticsStore; + const bar = () => + fixture.componentInstance as unknown as { + builderOpen: { set(v: boolean): void }; + onPath(v: unknown): void; + onOperator(v: unknown): void; + value: { set(v: string): void }; + add(): void; + canAdd(): boolean; + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CpsDiagnosticsFilterBarComponent, NoopAnimationsModule], + providers: [CpsDiagnosticsStore] + }).compileComponents(); + store = TestBed.inject(CpsDiagnosticsStore); + fixture = TestBed.createComponent(CpsDiagnosticsFilterBarComponent); + fixture.componentRef.setInput('section', 'logging'); + fixture.componentRef.setInput('sectionName', 'Logging'); + fixture.detectChanges(); + }); + + it('should need a field, and a value unless checking presence', () => { + bar().builderOpen.set(true); + fixture.detectChanges(); + expect(bar().canAdd()).toBe(false); + + bar().onPath('metadata.theme'); + expect(bar().canAdd()).toBe(false); + bar().value.set('dark'); + expect(bar().canAdd()).toBe(true); + + bar().value.set(''); + bar().onOperator('exists'); + expect(bar().canAdd()).toBe(true); + }); + + it('should name its controls after the section', () => { + const el = fixture.nativeElement as HTMLElement; + expect( + el + .querySelector('.cps-diagnostics-search input') + ?.getAttribute('aria-label') + ).toBe('Search Logging events'); + }); + + it('should add a filter as a removable chip with a spoken name', () => { + bar().onPath('metadata.theme'); + bar().onOperator('equals'); + bar().value.set('dark'); + bar().add(); + fixture.detectChanges(); + + // Written to this bar's own section only. + expect(store.filters().bi.fields).toHaveLength(0); + expect(store.filters().logging.fields).toEqual([ + expect.objectContaining({ + path: 'metadata.theme', + operator: 'equals', + value: 'dark' + }) + ]); + const close = fixture.nativeElement.querySelector( + 'cps-chip button' + ) as HTMLButtonElement; + expect(close.getAttribute('aria-label')).toBe( + 'Remove filter metadata.theme equals "dark"' + ); + + close.click(); + fixture.detectChanges(); + expect(store.filters().logging.fields).toHaveLength(0); + }); + + it('should clear the text and every filter together', () => { + store.setText('logging', 'x'); + store.addFilter('logging', { path: 'kind', operator: 'exists', value: '' }); + store.setText('bi', 'kept'); + fixture.detectChanges(); + + const clearBtn = Array.from( + (fixture.nativeElement as HTMLElement).querySelectorAll('button') + ).find((b) => b.textContent?.trim() === 'Clear filters'); + clearBtn?.click(); + + expect(store.filters().logging).toEqual({ text: '', fields: [] }); + // Another section's filters are left alone. + expect(store.filters().bi.text).toBe('kept'); + }); +}); diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-filter-bar/cps-diagnostics-filter-bar.component.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-filter-bar/cps-diagnostics-filter-bar.component.ts new file mode 100644 index 000000000..8001d2aff --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-filter-bar/cps-diagnostics-filter-bar.component.ts @@ -0,0 +1,142 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + inject, + input, + signal +} from '@angular/core'; +import { + CpsAutocompleteComponent, + CpsButtonComponent, + CpsChipComponent, + CpsInfoCircleComponent, + CpsInputComponent, + CpsSelectComponent +} from 'cps-ui-kit'; +import { + CpsDiagnosticsFilterOperator, + CpsDiagnosticsSectionId +} from '../cps-diagnostics.models/cps-diagnostics.models'; +import { CpsDiagnosticsStore } from '../cps-diagnostics-store/cps-diagnostics-store'; +import { + cpsDescribeFilter, + cpsOperatorNeedsValue +} from '../cps-diagnostics-filter/cps-diagnostics-filter'; + +/** Typing settles for this long before filtering runs, in milliseconds. */ +const SEARCH_DEBOUNCE_MS = 150; + +/** + * One section's free-text search, field-filter builder and active filters. + * Each section filters on its own; nothing here affects the others. + */ +@Component({ + selector: 'cps-diagnostics-filter-bar', + imports: [ + CpsInputComponent, + CpsAutocompleteComponent, + CpsSelectComponent, + CpsButtonComponent, + CpsChipComponent, + CpsInfoCircleComponent + ], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './cps-diagnostics-filter-bar.component.html', + styleUrl: './cps-diagnostics-filter-bar.component.scss' +}) +export class CpsDiagnosticsFilterBarComponent { + protected readonly store = inject(CpsDiagnosticsStore); + + /** The section these filters belong to. */ + readonly section = input.required<CpsDiagnosticsSectionId>(); + /** The section's name as people read it, e.g. `BI telemetry`. */ + readonly sectionName = input.required<string>(); + + protected readonly filters = computed( + () => this.store.filters()[this.section()] + ); + + protected readonly operators: { + label: string; + value: CpsDiagnosticsFilterOperator; + }[] = [ + { label: 'contains', value: 'contains' }, + { label: 'equals', value: 'equals' }, + { label: 'does not contain', value: 'not-contains' }, + { label: 'exists', value: 'exists' }, + { label: 'is missing', value: 'missing' } + ]; + + protected readonly builderOpen = signal(false); + protected readonly path = signal(''); + protected readonly operator = + signal<CpsDiagnosticsFilterOperator>('contains'); + + protected readonly value = signal(''); + + protected readonly pathOptions = computed(() => + this.store.knownPaths()[this.section()].map((p) => ({ label: p, value: p })) + ); + + protected readonly needsValue = computed(() => + cpsOperatorNeedsValue(this.operator()) + ); + + protected readonly canAdd = computed( + () => !!this.path().trim() && (!this.needsValue() || !!this.value().trim()) + ); + + protected readonly chips = computed(() => + this.filters().fields.map((f) => ({ + id: f.id, + text: cpsDescribeFilter(f) + })) + ); + + protected readonly anyActive = computed(() => { + const f = this.filters(); + return !!f.text.trim() || f.fields.length > 0; + }); + + private searchTimer?: ReturnType<typeof setTimeout>; + + constructor() { + inject(DestroyRef).onDestroy(() => clearTimeout(this.searchTimer)); + } + + protected onSearch(text: string): void { + clearTimeout(this.searchTimer); + this.searchTimer = setTimeout( + () => this.store.setText(this.section(), text ?? ''), + SEARCH_DEBOUNCE_MS + ); + } + + protected onPath(value: unknown): void { + this.path.set(typeof value === 'string' ? value : ''); + } + + protected onOperator(value: unknown): void { + const match = this.operators.find((o) => o.value === value); + this.operator.set(match?.value ?? 'contains'); + } + + protected add(): void { + if (!this.canAdd()) { + return; + } + this.store.addFilter(this.section(), { + path: this.path().trim(), + operator: this.operator(), + value: this.needsValue() ? this.value().trim() : '' + }); + this.value.set(''); + } + + protected clearAll(): void { + clearTimeout(this.searchTimer); + this.store.clearFilters(this.section()); + } +} diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-filter/cps-diagnostics-filter.spec.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-filter/cps-diagnostics-filter.spec.ts new file mode 100644 index 000000000..5b68aad59 --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-filter/cps-diagnostics-filter.spec.ts @@ -0,0 +1,234 @@ +import { CpsTelemetryObservedEvent } from 'cps-telemetry'; +import { + CpsDiagnosticsFieldFilter, + CpsDiagnosticsFilterState +} from '../cps-diagnostics.models/cps-diagnostics.models'; +import { + cpsDescribeFilter, + cpsDiagnosticsSectionOf, + cpsFlattenEvent, + cpsKnownPaths, + cpsMatchesFilters +} from './cps-diagnostics-filter'; + +const base = { + sequence: 7, + capturedAt: '2026-01-01T10:00:00.000Z', + destination: 'sink' as const, + origin: { forwarded: false as const } +}; + +const scenario: CpsTelemetryObservedEvent = { + ...base, + kind: 'scenario', + eventType: 'com.cps.scenario', + payload: { + scenarioId: 'abc-123', + scenarioName: 'load-dashboard', + status: 'failure', + startTime: '2026-01-01T09:59:59.000Z', + delta: 812, + elapsed: 5000, + stepCount: 2, + steps: [ + { name: 'scenario-start', startOffset: 0 }, + { name: 'fetch', startOffset: 10, status: 'success' }, + { name: 'render', startOffset: 400, status: 'failure' } + ], + metadata: { theme: 'Dark', retry: null }, + application: 'app' + } +}; + +function state( + text = '', + fields: Partial<CpsDiagnosticsFieldFilter>[] = [] +): CpsDiagnosticsFilterState { + return { + text, + fields: fields.map((f, i) => ({ + id: String(i), + path: '', + operator: 'contains', + value: '', + ...f + })) + }; +} + +describe('cpsFlattenEvent', () => { + const fields = cpsFlattenEvent(scenario); + const byPath = (p: string) => fields.find((f) => f.path === p); + + it('should expose nested payload fields at their own paths', () => { + expect(byPath('metadata.theme')?.value).toBe('Dark'); + expect(byPath('scenarioName')?.value).toBe('load-dashboard'); + }); + + it('should index array items, with an index-free pattern', () => { + expect(byPath('steps[2].name')).toEqual({ + path: 'steps[2].name', + pattern: 'steps[].name', + value: 'render' + }); + }); + + it('should include the envelope', () => { + expect(byPath('kind')?.value).toBe('scenario'); + expect(byPath('eventType')?.value).toBe('com.cps.scenario'); + expect(byPath('origin.forwarded')?.value).toBe('false'); + expect(byPath('sequence')?.value).toBe('7'); + }); + + it('should keep null as text, and address empty containers', () => { + expect(byPath('metadata.retry')?.value).toBe('null'); + const empty = cpsFlattenEvent({ + ...base, + kind: 'bi', + eventType: 'com.cps.bi', + payload: { tags: [], extra: {} } + }); + expect(empty.map((f) => f.path)).toEqual( + expect.arrayContaining(['tags', 'extra']) + ); + }); + + it('should survive a circular payload', () => { + const payload: Record<string, unknown> = { a: 1 }; + payload.self = payload; + const event = { + ...base, + kind: 'unknown', + eventType: 'x', + payload + } as unknown as CpsTelemetryObservedEvent; + + const result = cpsFlattenEvent(event); + expect(result.find((f) => f.path === 'self')?.value).toBe('[Circular]'); + }); + + it('should cap the number of fields for a huge payload', () => { + const payload = Object.fromEntries( + Array.from({ length: 5000 }, (_, i) => [`k${i}`, i]) + ); + const result = cpsFlattenEvent({ + ...base, + kind: 'bi', + eventType: 'com.cps.bi', + payload + }); + expect(result.length).toBeLessThanOrEqual(2000 + 10); + }); +}); + +describe('cpsMatchesFilters', () => { + const fields = cpsFlattenEvent(scenario); + const matches = (s: CpsDiagnosticsFilterState) => + cpsMatchesFilters(fields, s); + + it('should match everything with no filters', () => { + expect(matches(state())).toBe(true); + }); + + it('should match free text anywhere, ignoring case', () => { + expect(matches(state('DASHBOARD'))).toBe(true); + expect(matches(state('dark'))).toBe(true); + expect(matches(state('metadata.the'))).toBe(true); // path text too + expect(matches(state('nowhere'))).toBe(false); + }); + + it('should apply contains, equals and not-contains to one path', () => { + expect(matches(state('', [{ path: 'scenarioName', value: 'dash' }]))).toBe( + true + ); + expect( + matches( + state('', [{ path: 'scenarioName', operator: 'equals', value: 'dash' }]) + ) + ).toBe(false); + expect( + matches( + state('', [ + { path: 'status', operator: 'not-contains', value: 'success' } + ]) + ) + ).toBe(true); + }); + + it('should match any array item by pattern, or one item by index', () => { + expect( + matches(state('', [{ path: 'steps[].name', value: 'render' }])) + ).toBe(true); + expect( + matches(state('', [{ path: 'steps[1].name', value: 'render' }])) + ).toBe(false); + }); + + it('should treat exists and missing as presence checks', () => { + expect( + matches(state('', [{ path: 'metadata.theme', operator: 'exists' }])) + ).toBe(true); + expect( + matches(state('', [{ path: 'error.message', operator: 'missing' }])) + ).toBe(true); + }); + + it('should require every filter and the text together', () => { + expect( + matches( + state('dashboard', [ + { path: 'status', operator: 'equals', value: 'failure' }, + { path: 'metadata.theme', value: 'light' } + ]) + ) + ).toBe(false); + }); + + it('should find nothing for a path that does not exist', () => { + expect(matches(state('', [{ path: 'level', value: 'warn' }]))).toBe(false); + }); +}); + +describe('helpers', () => { + it('should put each kind in its section', () => { + expect(cpsDiagnosticsSectionOf('bi')).toBe('bi'); + expect(cpsDiagnosticsSectionOf('unknown')).toBe('bi'); + expect(cpsDiagnosticsSectionOf('scenario-step')).toBe('scenario'); + expect(cpsDiagnosticsSectionOf('error')).toBe('logging'); + }); + + it('should describe a filter as a short sentence', () => { + expect( + cpsDescribeFilter({ + id: '1', + path: 'level', + operator: 'not-contains', + value: 'log' + }) + ).toBe('level does not contain "log"'); + expect( + cpsDescribeFilter({ + id: '1', + path: 'error', + operator: 'exists', + value: '' + }) + ).toBe('error exists'); + }); + + it('should suggest envelope paths first, then the most common', () => { + const paths = cpsKnownPaths([ + cpsFlattenEvent(scenario), + cpsFlattenEvent(scenario) + ]); + expect(paths.slice(0, 5)).toEqual([ + 'kind', + 'eventType', + 'destination', + 'origin.application', + 'capturedAt' + ]); + expect(paths).toContain('steps[].name'); + expect(new Set(paths).size).toBe(paths.length); + }); +}); diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-filter/cps-diagnostics-filter.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-filter/cps-diagnostics-filter.ts new file mode 100644 index 000000000..ee578b510 --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-filter/cps-diagnostics-filter.ts @@ -0,0 +1,210 @@ +import { + CpsTelemetryEventKind, + CpsTelemetryObservedEvent +} from 'cps-telemetry'; +import { + CpsDiagnosticsField, + CpsDiagnosticsFieldFilter, + CpsDiagnosticsFilterOperator, + CpsDiagnosticsFilterState, + CpsDiagnosticsSectionId +} from '../cps-diagnostics.models/cps-diagnostics.models'; + +/** Deeper structures are cut off, so a pathological payload stays cheap. */ +const MAX_DEPTH = 12; +/** Leaves per event considered for filtering. */ +const MAX_FIELDS = 2000; +/** Characters of each value considered for matching. */ +const MAX_VALUE_CHARS = 2000; + +/** + * Which section an event is shown in. An event forwarded from another + * realm with an unrecognised type is shown with BI, alongside the other + * custom events. + */ +export function cpsDiagnosticsSectionOf( + kind: CpsTelemetryEventKind +): CpsDiagnosticsSectionId { + switch (kind) { + case 'scenario': + case 'scenario-step': + return 'scenario'; + case 'log': + case 'error': + return 'logging'; + default: + return 'bi'; + } +} + +/** + * Every leaf of an event as `path = value` text, done once when the event + * arrives so filtering never walks payloads again. + * + * Payload fields come first, at their own paths (`metadata.theme`, + * `steps[2].name`); the envelope follows (`kind`, `eventType`, + * `destination`, `origin.application`, `sequence`, `capturedAt`). Cycles, + * excessive depth and oversized strings are cut short rather than failing. + */ +export function cpsFlattenEvent( + event: CpsTelemetryObservedEvent +): CpsDiagnosticsField[] { + const fields: CpsDiagnosticsField[] = []; + const seen = new WeakSet<object>(); + + const visit = (value: unknown, path: string, depth: number): void => { + if (fields.length >= MAX_FIELDS) { + return; + } + if (value === null || typeof value !== 'object') { + if (value !== undefined) { + push(fields, path, String(value)); + } + return; + } + if (seen.has(value)) { + push(fields, path, '[Circular]'); + return; + } + if (depth >= MAX_DEPTH) { + push(fields, path, '[Too deep]'); + return; + } + seen.add(value); + + const entries: [string, unknown][] = Array.isArray(value) + ? value.map((v, i) => [`[${i}]`, v]) + : Object.entries(value).map(([k, v]) => [path ? `.${k}` : k, v]); + + if (entries.length === 0) { + // Still addressable, so `exists` can find an empty object or array. + push(fields, path, ''); + return; + } + for (const [suffix, child] of entries) { + visit(child, path + suffix, depth + 1); + } + }; + + visit(event.payload, '', 0); + + const { payload: _payload, ...envelope } = event; + visit(envelope, '', 0); + return fields; +} + +function push(fields: CpsDiagnosticsField[], path: string, value: string) { + fields.push({ + path, + pattern: path.replace(/\[\d+\]/g, '[]'), + value: + value.length > MAX_VALUE_CHARS ? value.slice(0, MAX_VALUE_CHARS) : value + }); +} + +/** + * Whether an event's fields satisfy every active filter. + * + * - Free text: case-insensitive, matched against every value and path. + * - A field filter's path matches a field's exact path or its pattern, so + * `steps[].name` covers every step and `steps[2].name` only the third. + * With several matching fields, one is enough. + * - Everything is combined with AND. + */ +export function cpsMatchesFilters( + fields: readonly CpsDiagnosticsField[], + filters: CpsDiagnosticsFilterState +): boolean { + const text = filters.text.trim().toLowerCase(); + if ( + text && + !fields.some( + (f) => + f.value.toLowerCase().includes(text) || + f.path.toLowerCase().includes(text) + ) + ) { + return false; + } + return filters.fields.every((filter) => matchesField(fields, filter)); +} + +function matchesField( + fields: readonly CpsDiagnosticsField[], + filter: CpsDiagnosticsFieldFilter +): boolean { + const path = filter.path.trim().toLowerCase(); + const value = filter.value.trim().toLowerCase(); + const atPath = fields.filter( + (f) => f.path.toLowerCase() === path || f.pattern.toLowerCase() === path + ); + + switch (filter.operator) { + case 'exists': + return atPath.length > 0; + case 'missing': + return atPath.length === 0; + case 'equals': + return atPath.some((f) => f.value.toLowerCase() === value); + case 'not-contains': + return !atPath.some((f) => f.value.toLowerCase().includes(value)); + case 'contains': + default: + return atPath.some((f) => f.value.toLowerCase().includes(value)); + } +} + +const OPERATOR_TEXT: Record<CpsDiagnosticsFilterOperator, string> = { + contains: 'contains', + equals: 'equals', + 'not-contains': 'does not contain', + exists: 'exists', + missing: 'is missing' +}; + +/** A filter as a short sentence, for chips and screen readers. */ +export function cpsDescribeFilter(filter: CpsDiagnosticsFieldFilter): string { + const op = OPERATOR_TEXT[filter.operator]; + return filter.operator === 'exists' || filter.operator === 'missing' + ? `${filter.path} ${op}` + : `${filter.path} ${op} "${filter.value}"`; +} + +/** Whether an operator compares against a value. */ +export function cpsOperatorNeedsValue( + operator: CpsDiagnosticsFilterOperator +): boolean { + return operator !== 'exists' && operator !== 'missing'; +} + +/** + * Field paths to suggest, from everything captured so far: envelope fields + * first, then the rest by how often they occur. + */ +export function cpsKnownPaths( + fieldSets: readonly (readonly CpsDiagnosticsField[])[] +): string[] { + const envelope = [ + 'kind', + 'eventType', + 'destination', + 'origin.application', + 'capturedAt' + ]; + const counts = new Map<string, number>(); + for (const fields of fieldSets) { + for (const f of new Set(fields.map((x) => x.pattern))) { + if ( + !envelope.includes(f) && + f !== 'sequence' && + f !== 'origin.forwarded' + ) { + counts.set(f, (counts.get(f) ?? 0) + 1); + } + } + } + const rest = [...counts.entries()] + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .map(([path]) => path); + return [...envelope, ...rest]; +} diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-internal.util/cps-diagnostics-internal.util.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-internal.util/cps-diagnostics-internal.util.ts new file mode 100644 index 000000000..9b9754a2c --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-internal.util/cps-diagnostics-internal.util.ts @@ -0,0 +1,53 @@ +import { isPlatformBrowser } from '@angular/common'; +import { inject, isDevMode, PLATFORM_ID } from '@angular/core'; + +/** + * A local copy of `cps-telemetry`'s fail-open helpers — + * `cpsSafe`/`cpsSafeVoid`/`cpsIsBrowser`/`cpsIsDevMode`. + * + * Not imported from the main entry point for the same reason as + * `cps-telemetry/rum`'s copy: ng-packagr fixes each entry point's `rootDir` + * to its own `src`, and the main entry keeps these internal on purpose. + * See `rum/src/lib/cps-rum-internal.util`. + */ + +/** Runs `fn`, returning `fallback` instead of ever throwing. */ +export function cpsSafe<T>(operation: string, fn: () => T, fallback: T): T { + try { + return fn(); + } catch (error) { + reportSuppressed(operation, error); + return fallback; + } +} + +/** Void-returning form of {@link cpsSafe}. */ +export function cpsSafeVoid(operation: string, fn: () => void): void { + cpsSafe(operation, fn, undefined); +} + +/** `isDevMode()` throws outside an Angular context; this never does. */ +export function cpsIsDevMode(): boolean { + try { + return isDevMode(); + } catch { + return false; + } +} + +/** Whether this runs in a browser. Needs an injection context. */ +export function cpsIsBrowser(): boolean { + return isPlatformBrowser(inject(PLATFORM_ID)); +} + +function reportSuppressed(operation: string, error: unknown): void { + if (!cpsIsDevMode()) { + return; + } + try { + // eslint-disable-next-line no-console + console.error(`[cps-telemetry] diagnostics.${operation} failed`, error); + } catch { + // A patched/throwing console must never escape suppression. + } +} diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-json/cps-diagnostics-json.component.html b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-json/cps-diagnostics-json.component.html new file mode 100644 index 000000000..fbc51a382 --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-json/cps-diagnostics-json.component.html @@ -0,0 +1,37 @@ +<div class="cps-diagnostics-json"> + <div class="cps-diagnostics-json__actions"> + <cps-button + type="borderless" + size="xsmall" + icon="copy" + [label]=" + copied() === 'copied' + ? 'Copied' + : copied() === 'failed' + ? 'Copy failed' + : 'Copy JSON' + " + ariaLabel="Copy this payload as JSON" + (clicked)="copy()"> + </cps-button> + <span role="status" class="cps-sr-only">{{ + copied() === 'copied' + ? 'Payload copied' + : copied() === 'failed' + ? 'Copying failed' + : '' + }}</span> + </div> + @if (truncated()) { + <p class="cps-diagnostics-json__notice"> + Large payload: showing the first {{ shownChars }} characters. The download + contains all of it. + </p> + } + <pre + class="cps-diagnostics-json__code" + tabindex="0" + role="region" + [attr.aria-label]="label()" + >{{ shown() }}</pre> +</div> diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-json/cps-diagnostics-json.component.scss b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-json/cps-diagnostics-json.component.scss new file mode 100644 index 000000000..a87c5042a --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-json/cps-diagnostics-json.component.scss @@ -0,0 +1,41 @@ +:host { + display: block; +} + +.cps-diagnostics-json { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.cps-diagnostics-json__actions { + display: flex; + justify-content: flex-end; +} + +.cps-diagnostics-json__notice { + margin: 0; + font-size: 0.8125rem; + color: var(--cps-state-warn); +} + +.cps-diagnostics-json__code { + margin: 0; + padding: 0.75rem; + max-height: 24rem; + overflow: auto; + background: var(--cps-surface-muted); + color: var(--cps-text-primary); + border: 1px solid var(--cps-border-color); + border-radius: var(--cps-radius-sm); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.75rem; + line-height: 1.5; + white-space: pre; + tab-size: 2; + + &:focus-visible { + outline: 2px solid var(--cps-ring-color); + outline-offset: 2px; + } +} diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-json/cps-diagnostics-json.component.spec.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-json/cps-diagnostics-json.component.spec.ts new file mode 100644 index 000000000..4396afa0a --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-json/cps-diagnostics-json.component.spec.ts @@ -0,0 +1,90 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { CpsDiagnosticsJsonComponent } from './cps-diagnostics-json.component'; + +describe('CpsDiagnosticsJsonComponent', () => { + let fixture: ComponentFixture<CpsDiagnosticsJsonComponent>; + const pre = () => fixture.nativeElement.querySelector('pre') as HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CpsDiagnosticsJsonComponent, NoopAnimationsModule] + }).compileComponents(); + fixture = TestBed.createComponent(CpsDiagnosticsJsonComponent); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('should show the payload as indented JSON in a named, focusable region', () => { + fixture.componentRef.setInput('value', { a: { b: 1 } }); + fixture.componentRef.setInput('label', 'Payload of event #3'); + fixture.detectChanges(); + + expect(JSON.parse(pre().textContent ?? '')).toEqual({ a: { b: 1 } }); + expect(pre().textContent).toContain('\n "a"'); + expect(pre().getAttribute('aria-label')).toBe('Payload of event #3'); + expect(pre().getAttribute('tabindex')).toBe('0'); + }); + + it('should truncate a huge payload in view, and say so', () => { + fixture.componentRef.setInput('value', { big: 'x'.repeat(100_000) }); + fixture.componentRef.setInput('maxChars', 1000); + fixture.detectChanges(); + + expect(pre().textContent!.length).toBe(64 * 1024); + expect(fixture.nativeElement.textContent).toContain( + 'showing the first 65,536 characters' + ); + expect(fixture.nativeElement.textContent).toContain( + 'The download contains all of it.' + ); + }); + + it('should measure the limit in characters, not bytes', () => { + // 600 Cyrillic letters: about 600 characters of JSON, but 1,200+ bytes. + fixture.componentRef.setInput('value', { text: 'ж'.repeat(600) }); + fixture.componentRef.setInput('maxChars', 1000); + fixture.detectChanges(); + + expect(fixture.nativeElement.textContent).not.toContain('Large payload'); + expect(pre().textContent).toContain('ж'.repeat(600)); + }); + + it('should not cut a truncated view through the middle of a surrogate pair', () => { + const before = '{\n "big": "'; + // Lines the first emoji up so the cut would fall inside it. + const padding = 'x'.repeat(64 * 1024 - 1 - before.length); + fixture.componentRef.setInput('value', { + big: padding + '😀'.repeat(10) + }); + fixture.componentRef.setInput('maxChars', 1000); + fixture.detectChanges(); + + const shown = pre().textContent!; + expect(shown.length).toBe(64 * 1024 - 1); + expect(shown.endsWith('x')).toBe(true); + }); + + it('should render a circular value instead of failing', () => { + const value: Record<string, unknown> = { a: 1 }; + value.self = value; + fixture.componentRef.setInput('value', value); + fixture.detectChanges(); + expect(pre().textContent).toContain('"[Circular]"'); + }); + + it('should copy the full JSON', async () => { + const writeText = jest.fn(() => Promise.resolve()); + Object.assign(navigator, { clipboard: { writeText } }); + fixture.componentRef.setInput('value', { a: 1 }); + fixture.detectChanges(); + + await ( + fixture.componentInstance as unknown as { copy(): Promise<void> } + ).copy(); + fixture.detectChanges(); + + expect(writeText).toHaveBeenCalledWith('{\n "a": 1\n}'); + expect(fixture.nativeElement.textContent).toContain('Payload copied'); + }); +}); diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-json/cps-diagnostics-json.component.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-json/cps-diagnostics-json.component.ts new file mode 100644 index 000000000..b57f0542f --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-json/cps-diagnostics-json.component.ts @@ -0,0 +1,82 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + signal +} from '@angular/core'; +import { CpsButtonComponent } from 'cps-ui-kit'; +import { cpsSafeStringify } from '../cps-diagnostics-export/cps-diagnostics-export'; +import { CPS_DEFAULT_DIAGNOSTICS_CONFIG } from '../cps-diagnostics-store/cps-diagnostics-store'; + +/** Characters shown of a payload too large to show in full. */ +const TRUNCATED_VIEW_CHARS = 64 * 1024; + +/** + * The first `length` characters of `text`, one fewer if the cut would split + * a surrogate pair — half an emoji renders as a replacement character. + */ +function cutAt(text: string, length: number): string { + const last = text.charCodeAt(length - 1); + const isHighSurrogate = last >= 0xd800 && last <= 0xdbff; + return text.slice(0, isHighSurrogate ? length - 1 : length); +} + +/** + * A payload as indented JSON, with a copy button. + * + * The only piece of the popup not taken from cps-ui-kit, which has no code + * or JSON display. It uses the kit's colour and radius tokens. + */ +@Component({ + selector: 'cps-diagnostics-json', + imports: [CpsButtonComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './cps-diagnostics-json.component.html', + styleUrl: './cps-diagnostics-json.component.scss' +}) +export class CpsDiagnosticsJsonComponent { + /** What to show. */ + readonly value = input.required<unknown>(); + + /** Accessible name of the scrollable block. */ + readonly label = input('Event payload'); + + /** JSON length, in characters, above which the view is truncated. */ + readonly maxChars = input( + CPS_DEFAULT_DIAGNOSTICS_CONFIG.maxPayloadCharsInView + ); + + /** How much a truncated view shows, as the notice words it. */ + protected readonly shownChars = TRUNCATED_VIEW_CHARS.toLocaleString('en-US'); + + protected readonly copied = signal<'idle' | 'copied' | 'failed'>('idle'); + + protected readonly full = computed(() => { + try { + return cpsSafeStringify(this.value(), 2) ?? 'undefined'; + } catch (error) { + return `Couldn't render this payload: ${ + error instanceof Error ? error.message : String(error) + }`; + } + }); + + protected readonly truncated = computed( + () => this.full().length > this.maxChars() + ); + + protected readonly shown = computed(() => + this.truncated() ? cutAt(this.full(), TRUNCATED_VIEW_CHARS) : this.full() + ); + + protected async copy(): Promise<void> { + try { + await navigator.clipboard.writeText(this.full()); + this.copied.set('copied'); + } catch { + this.copied.set('failed'); + } + setTimeout(() => this.copied.set('idle'), 2000); + } +} diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-section/cps-diagnostics-columns.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-section/cps-diagnostics-columns.ts new file mode 100644 index 000000000..a6cb9bfdf --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-section/cps-diagnostics-columns.ts @@ -0,0 +1,199 @@ +import { CpsTelemetryObservedEvent } from 'cps-telemetry'; +import { + CpsDiagnosticsEntry, + CpsDiagnosticsSectionId +} from '../cps-diagnostics.models/cps-diagnostics.models'; + +/** One column of a section's table. */ +export interface CpsDiagnosticsColumn { + key: string; + header: string; + /** Hidden on narrow screens. */ + secondary?: boolean; + value: (event: CpsTelemetryObservedEvent) => string; +} + +/** One table row: display text, plus the entry for the detail view. */ +export interface CpsDiagnosticsRow { + sequence: number; + time: string; + cells: Record<string, string>; + forwardedFrom?: string; + entry: CpsDiagnosticsEntry; +} + +/** Reads a field as text, whatever the payload's declared type. */ +function text(payload: unknown, key: string): string { + if (payload === null || typeof payload !== 'object') { + return ''; + } + const value = (payload as Record<string, unknown>)[key]; + return value === undefined || value === null ? '' : String(value); +} + +function metadataSummary(payload: unknown): string { + if (payload === null || typeof payload !== 'object') { + return ''; + } + const metadata = (payload as Record<string, unknown>).metadata; + if (metadata === null || typeof metadata !== 'object') { + return ''; + } + const pairs = Object.entries(metadata as Record<string, unknown>); + const shown = pairs + .slice(0, 3) + .map(([k, v]) => `${k}=${String(v)}`) + .join(', '); + return pairs.length > 3 ? `${shown}, …` : shown; +} + +function ms(value: string): string { + return value === '' ? '' : `${value} ms`; +} + +function eventTypeOf(event: CpsTelemetryObservedEvent): string { + return 'eventType' in event ? event.eventType : ''; +} + +export const CPS_DIAGNOSTICS_COLUMNS: Record< + CpsDiagnosticsSectionId, + readonly CpsDiagnosticsColumn[] +> = { + bi: [ + { + key: 'name', + header: 'Event', + value: (e) => + e.kind === 'unknown' + ? `${eventTypeOf(e)} (unrecognised type)` + : text(e.payload, 'eventName') + }, + { key: 'type', header: 'Event type', secondary: true, value: eventTypeOf }, + { + key: 'scenario', + header: 'Scenario id', + secondary: true, + value: (e) => text(e.payload, 'scenarioId') + }, + { + key: 'feature', + header: 'Feature', + secondary: true, + value: (e) => text(e.payload, 'feature') + }, + { + key: 'metadata', + header: 'Metadata', + value: (e) => metadataSummary(e.payload) + } + ], + scenario: [ + { + key: 'name', + header: 'Scenario', + value: (e) => text(e.payload, 'scenarioName') + }, + { + key: 'kind', + header: 'Event', + value: (e) => (e.kind === 'scenario-step' ? 'step' : 'record') + }, + { + key: 'status', + header: 'Status', + value: (e) => + e.kind === 'scenario-step' + ? [text(e.payload, 'name'), text(e.payload, 'status')] + .filter(Boolean) + .join(' · ') + : text(e.payload, 'status') + }, + { + key: 'duration', + header: 'Duration', + value: (e) => + ms(text(e.payload, e.kind === 'scenario-step' ? 'stepDelta' : 'delta')) + }, + { + key: 'steps', + header: 'Steps', + secondary: true, + value: (e) => text(e.payload, 'stepCount') + }, + { + key: 'id', + header: 'Scenario id', + secondary: true, + value: (e) => text(e.payload, 'scenarioId') + } + ], + logging: [ + { + key: 'level', + header: 'Level', + value: (e) => + e.kind === 'error' ? 'error → RUM' : text(e.payload, 'level') + }, + { + key: 'logger', + header: 'Logger', + value: (e) => text(e.payload, 'logger') + }, + { + key: 'context', + header: 'Context', + secondary: true, + value: (e) => text(e.payload, 'context') + }, + { + key: 'message', + header: 'Message', + value: (e) => + e.kind === 'error' + ? `${text(e.payload, 'name')}: ${text(e.payload, 'message')}` + : text(e.payload, 'message') + }, + { + key: 'correlation', + header: 'Correlation id', + secondary: true, + value: (e) => text(e.payload, 'correlationId') + } + ] +}; + +/** `HH:MM:SS.mmm` in local time — the precision that orders a burst. */ +export function cpsDiagnosticsTime(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) { + return iso; + } + const pad = (n: number, width = 2) => String(n).padStart(width, '0'); + return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad( + date.getSeconds() + )}.${pad(date.getMilliseconds(), 3)}`; +} + +export function cpsDiagnosticsRow( + entry: CpsDiagnosticsEntry, + columns: readonly CpsDiagnosticsColumn[] +): CpsDiagnosticsRow { + const { event } = entry; + const cells: Record<string, string> = {}; + for (const column of columns) { + try { + cells[column.key] = column.value(event); + } catch { + cells[column.key] = ''; + } + } + return { + sequence: event.sequence, + time: cpsDiagnosticsTime(event.capturedAt), + cells, + forwardedFrom: event.origin.forwarded + ? (event.origin.application ?? 'another app') + : undefined, + entry + }; +} diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-section/cps-diagnostics-section.component.html b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-section/cps-diagnostics-section.component.html new file mode 100644 index 000000000..6e015e00f --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-section/cps-diagnostics-section.component.html @@ -0,0 +1,161 @@ +<cps-expansion-panel + [headerTitle]="title()" + [prefixIcon]="text().icon" + [isExpanded]="true" + borderColor="line-mid"> + <div class="cps-diagnostics-section"> + <div class="cps-diagnostics-section__tools"> + <cps-diagnostics-filter-bar + class="cps-diagnostics-section__filters" + [section]="section()" + [sectionName]="text().title"> + </cps-diagnostics-filter-bar> + <div class="cps-diagnostics-section__actions"> + <cps-button + type="outlined" + size="small" + icon="copy" + [label]=" + copyState() === 'copied' + ? 'Copied' + : copyState() === 'failed' + ? 'Copy failed' + : 'Copy JSON' + " + [ariaLabel]="'Copy ' + text().title + ' events as JSON'" + (clicked)="copy()"> + </cps-button> + <cps-button + size="small" + icon="download" + label="Download JSON" + [ariaLabel]=" + 'Download ' + + text().title + + ' events as JSON. Contains session and user ids.' + " + (clicked)="download()"> + </cps-button> + </div> + </div> + @if (unseenNew() > 0) { + <div class="cps-diagnostics-section__notice"> + <span>{{ unseenNew() }} new on the first page.</span> + <cps-button + type="borderless" + size="xsmall" + label="Go to newest" + (clicked)="goToNewest()"> + </cps-button> + </div> + } + <cps-table + [data]="rows()" + dataKey="sequence" + [hasToolbar]="false" + size="small" + [striped]="true" + [paginator]="true" + [rows]="25" + [rowsPerPageOptions]="[25, 50, 100]" + [first]="first()" + (pageChanged)="onPage($event)" + [emptyMessage]="emptyMessage()"> + <ng-template #header> + <th scope="col">Time</th> + @for (column of columns(); track column.key) { + <th + scope="col" + [class.cps-diagnostics-section__secondary]="column.secondary"> + {{ column.header }} + </th> + } + </ng-template> + + <ng-template let-row #body> + <td class="cps-diagnostics-section__time"> + {{ row.time }} + @if (isFresh(row.sequence)) { + <cps-chip label="New"></cps-chip> + } + </td> + @for (column of columns(); track column.key) { + <td + [class.cps-diagnostics-section__secondary]="column.secondary" + [class.cps-diagnostics-section__level--warn]=" + column.key === 'level' && row.cells['level'] === 'warn' + " + [class.cps-diagnostics-section__level--error]=" + column.key === 'level' && row.cells['level'].startsWith('error') + "> + @if (column.key === columns()[0].key && row.forwardedFrom) { + <span class="cps-diagnostics-section__forwarded" + >↪ {{ row.forwardedFrom }}</span + > + } + <span class="cps-diagnostics-section__cell">{{ + row.cells[column.key] + }}</span> + </td> + } + </ng-template> + + <ng-template let-row #rowexpansion> + <div class="cps-diagnostics-section__detail"> + <dl class="cps-diagnostics-section__meta"> + <div> + <dt>Handed to</dt> + <dd> + {{ + row.entry.event.destination === 'sink' + ? 'telemetry sink' + : 'log API provider' + }} + </dd> + </div> + <div> + <dt>From</dt> + <dd> + {{ + row.forwardedFrom + ? 'forwarded by ' + row.forwardedFrom + : 'this app' + }} + </dd> + </div> + @if ('eventType' in row.entry.event) { + <div> + <dt>Event type</dt> + <dd>{{ row.entry.event.eventType }}</dd> + </div> + } + @if ( + row.entry.event.kind === 'error' && + row.entry.event.relatedSequence + ) { + <div> + <dt>Mirrored from</dt> + <dd>log event #{{ row.entry.event.relatedSequence }}</dd> + </div> + } + <div> + <dt>Event</dt> + <dd>#{{ row.sequence }}, {{ row.entry.sizeChars }} characters</dd> + </div> + </dl> + @if (row.entry.renderError) { + <p class="cps-diagnostics-section__error"> + Couldn't prepare this event for display: + {{ row.entry.renderError }} + </p> + } + <cps-diagnostics-json + [value]="row.entry.event.payload" + [label]="'Payload of event #' + row.sequence" + [maxChars]="maxPayloadChars()"> + </cps-diagnostics-json> + </div> + </ng-template> + </cps-table> + </div> +</cps-expansion-panel> diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-section/cps-diagnostics-section.component.scss b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-section/cps-diagnostics-section.component.scss new file mode 100644 index 000000000..d35e0da24 --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-section/cps-diagnostics-section.component.scss @@ -0,0 +1,108 @@ +:host { + display: block; + container-type: inline-size; +} + +.cps-diagnostics-section { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-block: 0.5rem; +} + +.cps-diagnostics-section__tools { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 0.5rem 1rem; +} + +.cps-diagnostics-section__filters { + flex: 1 1 24rem; +} + +.cps-diagnostics-section__actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.cps-diagnostics-section__notice { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + color: var(--cps-text-secondary); +} + +.cps-diagnostics-section__time { + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.cps-diagnostics-section__cell { + display: inline-block; + max-width: 28rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; +} + +.cps-diagnostics-section__forwarded { + margin-inline-end: 0.375rem; + color: var(--cps-text-secondary); + font-size: 0.75rem; +} + +// Level is always written out; colour only supports it. +.cps-diagnostics-section__level--warn { + color: var(--cps-state-warn); + font-weight: 600; +} + +.cps-diagnostics-section__level--error { + color: var(--cps-state-error); + font-weight: 600; +} + +.cps-diagnostics-section__detail { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem 0.25rem; +} + +.cps-diagnostics-section__meta { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1.25rem; + margin: 0; + font-size: 0.8125rem; + + div { + display: flex; + gap: 0.375rem; + } + + dt { + color: var(--cps-text-secondary); + } + + dd { + margin: 0; + color: var(--cps-text-primary); + } +} + +.cps-diagnostics-section__error { + margin: 0; + color: var(--cps-state-error); + font-size: 0.8125rem; +} + +@container (max-width: 48rem) { + .cps-diagnostics-section__secondary { + display: none; + } +} diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-section/cps-diagnostics-section.component.spec.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-section/cps-diagnostics-section.component.spec.ts new file mode 100644 index 000000000..e39c9ac9e --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-section/cps-diagnostics-section.component.spec.ts @@ -0,0 +1,126 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { CpsTelemetryMonitor } from 'cps-telemetry'; +import { CpsDiagnosticsExporter } from '../cps-diagnostics-export/cps-diagnostics-exporter'; +import { + CPS_DIAGNOSTICS_FLUSH_MS, + CpsDiagnosticsStore +} from '../cps-diagnostics-store/cps-diagnostics-store'; +import { CpsDiagnosticsSectionComponent } from './cps-diagnostics-section.component'; + +/** Rows per page, as the section's table is configured. */ +const PAGE = 25; + +describe('CpsDiagnosticsSectionComponent', () => { + let fixture: ComponentFixture<CpsDiagnosticsSectionComponent>; + let store: CpsDiagnosticsStore; + let monitor: CpsTelemetryMonitor; + + const el = () => fixture.nativeElement as HTMLElement; + const shownRows = () => + el().querySelectorAll('td.cps-diagnostics-section__time').length; + const pageReport = () => + el().querySelector('.p-paginator-current')?.textContent?.trim(); + const ui = () => + fixture.componentInstance as unknown as { + first(): number; + onPage(state: { first?: number }): void; + }; + + /** Publishes BI events, every tenth one named `needle`. */ + function publish(count: number): void { + for (let i = 0; i < count; i++) { + monitor.publish({ + kind: 'bi', + eventType: 'com.cps.bi', + payload: { + eventName: i % 10 === 0 ? 'needle' : `hay-${i}`, + eventTime: '2026-01-01T00:00:00.000Z', + application: 'app' + }, + destination: 'sink', + origin: { forwarded: false } + }); + } + jest.advanceTimersByTime(CPS_DIAGNOSTICS_FLUSH_MS); + fixture.detectChanges(); + } + + function goToPage(page: number): void { + ui().onPage({ first: (page - 1) * PAGE }); + fixture.detectChanges(); + } + + beforeEach(async () => { + jest.useFakeTimers(); + await TestBed.configureTestingModule({ + imports: [CpsDiagnosticsSectionComponent, NoopAnimationsModule], + providers: [CpsDiagnosticsStore, CpsDiagnosticsExporter] + }).compileComponents(); + monitor = TestBed.inject(CpsTelemetryMonitor); + store = TestBed.inject(CpsDiagnosticsStore); + fixture = TestBed.createComponent(CpsDiagnosticsSectionComponent); + fixture.componentRef.setInput('section', 'bi'); + fixture.detectChanges(); + }); + + afterEach(() => { + fixture.destroy(); + jest.useRealTimers(); + }); + + describe('paging', () => { + it('should show the page the reader chose', () => { + publish(100); + goToPage(4); + + expect(ui().first()).toBe(75); + expect(shownRows()).toBe(PAGE); + }); + + it('should stay on the page while new events arrive', () => { + publish(100); + goToPage(2); + + publish(1); + + expect(ui().first()).toBe(PAGE); + }); + + it('should go back to the first page when a filter leaves fewer rows than the page starts at', () => { + publish(100); + goToPage(4); + + store.setText('bi', 'needle'); + fixture.detectChanges(); + + expect(ui().first()).toBe(0); + expect(shownRows()).toBe(10); + expect(pageReport()).toBe('1 - 10 of 10'); + }); + + it('should keep the page when a filter still reaches it', () => { + publish(100); + goToPage(2); + + store.setText('bi', 'hay'); + fixture.detectChanges(); + + expect(ui().first()).toBe(PAGE); + expect(shownRows()).toBe(PAGE); + }); + + it('should go back to the first page when the section is cleared', () => { + publish(100); + goToPage(3); + + store.clear(); + fixture.detectChanges(); + expect(ui().first()).toBe(0); + + publish(1); + expect(shownRows()).toBe(1); + expect(pageReport()).toBe('1 - 1 of 1'); + }); + }); +}); diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-section/cps-diagnostics-section.component.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-section/cps-diagnostics-section.component.ts new file mode 100644 index 000000000..1fcecb322 --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-section/cps-diagnostics-section.component.ts @@ -0,0 +1,168 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + linkedSignal, + signal +} from '@angular/core'; +import { + CpsChipComponent, + CpsButtonComponent, + CpsExpansionPanelComponent, + CpsIconType, + CpsTableComponent +} from 'cps-ui-kit'; +import { CpsDiagnosticsSectionId } from '../cps-diagnostics.models/cps-diagnostics.models'; +import { + CPS_DEFAULT_DIAGNOSTICS_CONFIG, + CpsDiagnosticsStore +} from '../cps-diagnostics-store/cps-diagnostics-store'; +import { CpsDiagnosticsJsonComponent } from '../cps-diagnostics-json/cps-diagnostics-json.component'; +import { CpsDiagnosticsFilterBarComponent } from '../cps-diagnostics-filter-bar/cps-diagnostics-filter-bar.component'; +import { CpsDiagnosticsExporter } from '../cps-diagnostics-export/cps-diagnostics-exporter'; +import { + CPS_DIAGNOSTICS_COLUMNS, + cpsDiagnosticsRow, + cpsDiagnosticsTime +} from './cps-diagnostics-columns'; + +const SECTION_TEXT: Record< + CpsDiagnosticsSectionId, + { title: string; icon: CpsIconType; empty: string } +> = { + bi: { + title: 'BI telemetry', + icon: 'graph', + empty: 'No BI events since this window opened.' + }, + scenario: { + title: 'Scenario telemetry', + icon: 'measurement', + empty: 'No scenario events since this window opened.' + }, + logging: { + title: 'Logging', + icon: 'book', + empty: 'No log records since this window opened.' + } +}; + +/** + * One section of the popup: an expander holding the section's own filters, + * its download and copy actions, and a paginated table of its events. + */ +@Component({ + selector: 'cps-diagnostics-section', + imports: [ + CpsExpansionPanelComponent, + CpsTableComponent, + CpsChipComponent, + CpsButtonComponent, + CpsDiagnosticsJsonComponent, + CpsDiagnosticsFilterBarComponent + ], + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './cps-diagnostics-section.component.html', + styleUrl: './cps-diagnostics-section.component.scss' +}) +export class CpsDiagnosticsSectionComponent { + protected readonly store = inject(CpsDiagnosticsStore); + private readonly exporter = inject(CpsDiagnosticsExporter); + + readonly section = input.required<CpsDiagnosticsSectionId>(); + /** JSON length, in characters, above which a payload is shown truncated. */ + readonly maxPayloadChars = input( + CPS_DEFAULT_DIAGNOSTICS_CONFIG.maxPayloadCharsInView + ); + + protected readonly copyState = signal<'idle' | 'copied' | 'failed'>('idle'); + + protected readonly text = computed(() => SECTION_TEXT[this.section()]); + protected readonly columns = computed( + () => CPS_DIAGNOSTICS_COLUMNS[this.section()] + ); + + protected readonly rows = computed(() => { + const columns = this.columns(); + return this.store + .filtered() + [this.section()].map((entry) => cpsDiagnosticsRow(entry, columns)); + }); + + /** + * The paginator's offset. Kept while it still lands on a row; otherwise — + * a filter or a clear shrinking the rows past it — back to the first page, + * where the newest events are. The table won't do this itself: PrimeNG + * steps back one page at most, so page four filtered down to one page + * would show an empty table. + */ + protected readonly first = linkedSignal<readonly unknown[], number>({ + source: this.rows, + computation: (rows, previous) => + previous && previous.value < rows.length ? previous.value : 0 + }); + + private readonly captured = computed( + () => this.store.entries()[this.section()] + ); + + /** The expander title — the kit's header takes plain text only. */ + protected readonly title = computed(() => { + const { title } = this.text(); + const captured = this.captured(); + if (captured.length === 0) { + return `${title} · no events yet`; + } + const count = this.store.isFiltering(this.section()) + ? `${this.rows().length} of ${captured.length}` + : `${captured.length}`; + const last = cpsDiagnosticsTime(captured[0].event.capturedAt).slice(0, 8); + const dropped = this.store.dropped()[this.section()]; + return [ + title, + count, + `last ${last}`, + dropped ? `oldest ${dropped} dropped` : '' + ] + .filter(Boolean) + .join(' · '); + }); + + protected readonly emptyMessage = computed(() => + this.captured().length > 0 + ? 'No events match the filters.' + : this.text().empty + ); + + /** Fresh arrivals that a reader past page one cannot see. */ + protected readonly unseenNew = computed(() => + this.first() > 0 + ? this.rows().filter((r) => this.store.fresh().has(r.sequence)).length + : 0 + ); + + protected onPage(state: { first?: number }): void { + this.first.set(state?.first ?? 0); + } + + protected goToNewest(): void { + this.first.set(0); + } + + protected download(): void { + this.exporter.download(this.section()); + } + + protected async copy(): Promise<void> { + this.copyState.set( + (await this.exporter.copy(this.section())) ? 'copied' : 'failed' + ); + setTimeout(() => this.copyState.set('idle'), 2000); + } + + protected isFresh(sequence: number): boolean { + return this.store.fresh().has(sequence); + } +} diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-shortcut/cps-diagnostics-shortcut.spec.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-shortcut/cps-diagnostics-shortcut.spec.ts new file mode 100644 index 000000000..aa8f5a09d --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-shortcut/cps-diagnostics-shortcut.spec.ts @@ -0,0 +1,104 @@ +import { + CPS_DEFAULT_DIAGNOSTICS_SHORTCUTS, + cpsIsApplePlatform, + cpsMatchesShortcut, + cpsShortcutLabel +} from './cps-diagnostics-shortcut'; + +type Mods = Partial< + Pick<KeyboardEvent, 'ctrlKey' | 'altKey' | 'shiftKey' | 'metaKey' | 'repeat'> +>; + +function key(code: string, mods: Mods, keyChar = '8', altGraph = false) { + const event = new KeyboardEvent('keydown', { code, key: keyChar, ...mods }); + Object.defineProperty(event, 'getModifierState', { + value: (k: string) => k === 'AltGraph' && altGraph + }); + return event; +} + +const [MAC, WIN] = CPS_DEFAULT_DIAGNOSTICS_SHORTCUTS; +const macMods = { shiftKey: true, altKey: true, metaKey: true }; +const winMods = { ctrlKey: true, altKey: true, shiftKey: true }; + +describe('cpsMatchesShortcut', () => { + it('should match ⇧⌥⌘8, even though Option and Shift change the character', () => { + // On macOS, ⇧⌥8 produces "°" — only the physical key is reliable. + expect(cpsMatchesShortcut(key('Digit8', macMods, '°'), MAC)).toBe(true); + }); + + it('should match Ctrl+Alt+Shift+8', () => { + expect(cpsMatchesShortcut(key('Digit8', winMods, '*'), WIN)).toBe(true); + }); + + it.each([ + ['a modifier missing', { shiftKey: true, altKey: true }], + ['an extra modifier', { ...macMods, ctrlKey: true }], + ['no modifiers', {}] + ])('should not match with %s', (_label, mods) => { + expect(cpsMatchesShortcut(key('Digit8', mods), MAC)).toBe(false); + }); + + it('should not match the numpad 8', () => { + expect(cpsMatchesShortcut(key('Numpad8', macMods), MAC)).toBe(false); + }); + + it('should ignore a held-down repeat', () => { + expect( + cpsMatchesShortcut(key('Digit8', { ...winMods, repeat: true }), WIN) + ).toBe(false); + }); + + it('should not fire while AltGr is producing a character', () => { + // AltGr reports as Ctrl+Alt on many European Windows layouts. + expect(cpsMatchesShortcut(key('Digit8', winMods, '8', true), WIN)).toBe( + false + ); + }); + + it('should match a custom shortcut', () => { + const custom = { code: 'KeyD', ctrl: true, alt: true, shift: true }; + expect(cpsMatchesShortcut(key('KeyD', winMods, 'D'), custom)).toBe(true); + expect(cpsMatchesShortcut(key('Digit8', winMods), custom)).toBe(false); + }); +}); + +describe('cpsShortcutLabel', () => { + it('should show the macOS label on Apple platforms, and the other elsewhere', () => { + expect(cpsShortcutLabel(CPS_DEFAULT_DIAGNOSTICS_SHORTCUTS, true)).toBe( + '⇧⌥⌘8' + ); + expect(cpsShortcutLabel(CPS_DEFAULT_DIAGNOSTICS_SHORTCUTS, false)).toBe( + 'Ctrl+Alt+Shift+8' + ); + }); + + it('should describe a shortcut that has no label', () => { + expect( + cpsShortcutLabel([{ code: 'KeyD', ctrl: true, shift: true }], false) + ).toBe('Ctrl+Shift+D'); + }); + + it('should be undefined with no shortcuts', () => { + expect(cpsShortcutLabel([], false)).toBeUndefined(); + }); +}); + +describe('cpsIsApplePlatform', () => { + it.each([ + ['MacIntel', true], + ['iPad', true], + ['Win32', false], + ['Linux x86_64', false] + ])('should read %s as %s', (platform, expected) => { + expect(cpsIsApplePlatform({ platform } as Navigator)).toBe(expected); + }); + + it('should prefer userAgentData when present', () => { + const nav = { + platform: 'Win32', + userAgentData: { platform: 'macOS' } + } as unknown as Navigator; + expect(cpsIsApplePlatform(nav)).toBe(true); + }); +}); diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-shortcut/cps-diagnostics-shortcut.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-shortcut/cps-diagnostics-shortcut.ts new file mode 100644 index 000000000..b3d0c7dbc --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-shortcut/cps-diagnostics-shortcut.ts @@ -0,0 +1,92 @@ +import { CpsDiagnosticsShortcut } from '../cps-diagnostics.models/cps-diagnostics.models'; + +/** + * The default shortcuts: ⇧⌥⌘8 on macOS, Ctrl+Alt+Shift+8 on Windows and + * Linux. + * + * Four keys so it is never pressed by accident, ending in a digit because + * every layout has one. Both are active everywhere — the other platform's + * combination is harmless, and covers external keyboards and remote + * desktops. None is bound by Chrome, Safari, Edge or Firefox; ⌥⌘8 alone is + * macOS Accessibility Zoom, which is why Shift is part of it. + * + * @group Utils + */ +export const CPS_DEFAULT_DIAGNOSTICS_SHORTCUTS: readonly CpsDiagnosticsShortcut[] = + [ + { code: 'Digit8', shift: true, alt: true, meta: true, label: '⇧⌥⌘8' }, + { + code: 'Digit8', + ctrl: true, + alt: true, + shift: true, + label: 'Ctrl+Alt+Shift+8' + } + ]; + +/** + * Whether `event` is exactly `shortcut`. + * + * - The physical key is compared (`code`), never the produced character — + * Option turns 8 into `•` on macOS, and Shift turns it into `*`. + * - Every modifier must match; one left out must be up. + * - Held-down repeats don't count. + * - AltGr is rejected. On many European Windows layouts it reports as + * Ctrl+Alt, and typing an AltGr character must never open the popup. + */ +export function cpsMatchesShortcut( + event: KeyboardEvent, + shortcut: CpsDiagnosticsShortcut +): boolean { + if (event.repeat || event.code !== shortcut.code) { + return false; + } + if (event.getModifierState?.('AltGraph')) { + return false; + } + return ( + event.ctrlKey === !!shortcut.ctrl && + event.altKey === !!shortcut.alt && + event.shiftKey === !!shortcut.shift && + event.metaKey === !!shortcut.meta + ); +} + +/** Whether the platform is Apple's, for choosing which label to show. */ +export function cpsIsApplePlatform(nav: Navigator | undefined): boolean { + if (!nav) { + return false; + } + const platform = + (nav as Navigator & { userAgentData?: { platform?: string } }).userAgentData + ?.platform ?? + nav.platform ?? + ''; + return /mac|iphone|ipad/i.test(platform); +} + +/** + * The shortcut to show people: the one written for this platform when + * there is one, otherwise the first configured. + */ +export function cpsShortcutLabel( + shortcuts: readonly CpsDiagnosticsShortcut[], + apple: boolean +): string | undefined { + const preferred = shortcuts.find((s) => !!s.meta === apple); + const chosen = preferred ?? shortcuts[0]; + return chosen ? (chosen.label ?? describeShortcut(chosen)) : undefined; +} + +function describeShortcut(s: CpsDiagnosticsShortcut): string { + const key = s.code.replace(/^Digit|^Key/, ''); + return [ + s.ctrl && 'Ctrl', + s.alt && 'Alt', + s.shift && 'Shift', + s.meta && 'Meta', + key + ] + .filter(Boolean) + .join('+'); +} diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-store/cps-diagnostics-store.spec.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-store/cps-diagnostics-store.spec.ts new file mode 100644 index 000000000..2ed898d0b --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-store/cps-diagnostics-store.spec.ts @@ -0,0 +1,261 @@ +import { Injector } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CpsTelemetryMonitor, CpsTelemetryPublishInput } from 'cps-telemetry'; +import { CPS_TELEMETRY_DIAGNOSTICS_CONFIG } from '../cps-diagnostics.models/cps-diagnostics.models'; +import { + CPS_DIAGNOSTICS_FLUSH_MS, + CpsDiagnosticsStore +} from './cps-diagnostics-store'; + +const bi = (name: string): CpsTelemetryPublishInput => ({ + kind: 'bi', + eventType: 'com.cps.bi', + payload: { + eventName: name, + eventTime: '2026-01-01T00:00:00.000Z', + application: 'app' + }, + destination: 'sink', + origin: { forwarded: false } +}); + +const log = (message: string): CpsTelemetryPublishInput => ({ + kind: 'log', + payload: { + timestamp: '2026-01-01T00:00:00.000Z', + level: 'warn', + message, + logger: 'checkout', + application: 'app', + environment: 'test', + version: '1' + }, + destination: 'log-provider', + origin: { forwarded: false } +}); + +describe('CpsDiagnosticsStore', () => { + let monitor: CpsTelemetryMonitor; + let injector: Injector; + + function createStore(max = 500): CpsDiagnosticsStore { + injector = Injector.create({ + providers: [ + CpsDiagnosticsStore, + { + provide: CPS_TELEMETRY_DIAGNOSTICS_CONFIG, + useValue: { maxEventsPerSection: max } + } + ], + parent: TestBed.inject(Injector) + }); + return injector.get(CpsDiagnosticsStore); + } + + const flush = () => jest.advanceTimersByTime(CPS_DIAGNOSTICS_FLUSH_MS); + const names = (store: CpsDiagnosticsStore) => + store + .entries() + .bi.map((e) => (e.event.payload as { eventName: string }).eventName); + + beforeEach(() => { + jest.useFakeTimers(); + TestBed.resetTestingModule(); + monitor = TestBed.inject(CpsTelemetryMonitor); + }); + + function destroy(): void { + const target = injector as unknown as { + destroy(): void; + destroyed: boolean; + }; + if (target && !target.destroyed) { + target.destroy(); + } + } + + afterEach(() => { + destroy(); + jest.useRealTimers(); + }); + + it('should start empty, and ignore everything sent before it existed', () => { + monitor.publish(bi('before')); // nobody observing yet + const store = createStore(); + flush(); + + expect(store.total()).toBe(0); + }); + + it('should batch arrivals into one update per flush', () => { + const store = createStore(); + monitor.publish(bi('a')); + monitor.publish(bi('b')); + + expect(store.total()).toBe(0); // not on screen yet + flush(); + expect(names(store)).toEqual(['b', 'a']); // newest first + }); + + it('should route each kind to its section', () => { + const store = createStore(); + monitor.publish(bi('a')); + monitor.publish(log('careful')); + flush(); + + expect(store.entries().bi).toHaveLength(1); + expect(store.entries().logging).toHaveLength(1); + expect(store.entries().scenario).toHaveLength(0); + }); + + it('should keep at most the configured number per section, counting drops', () => { + const store = createStore(2); + ['a', 'b', 'c', 'd'].forEach((n) => monitor.publish(bi(n))); + monitor.publish(log('kept')); + flush(); + + expect(names(store)).toEqual(['d', 'c']); + expect(store.dropped().bi).toBe(2); + // A noisy section never evicts another. + expect(store.entries().logging).toHaveLength(1); + }); + + it('should clear everything and keep capturing', () => { + const store = createStore(); + monitor.publish(bi('a')); + flush(); + + store.clear(); + expect(store.total()).toBe(0); + + monitor.publish(bi('after')); + flush(); + expect(names(store)).toEqual(['after']); + }); + + it('should freeze the view while paused, but keep capturing', () => { + const store = createStore(); + monitor.publish(bi('a')); + flush(); + + store.setPaused(true); + monitor.publish(bi('b')); + monitor.publish(bi('c')); + flush(); + expect(names(store)).toEqual(['a']); + expect(store.newWhilePaused()).toBe(2); + // The section's download still contains them. + expect(store.sectionEvents('bi')).toHaveLength(3); + + store.setPaused(false); + expect(names(store)).toEqual(['c', 'b', 'a']); + expect(store.newWhilePaused()).toBe(0); + }); + + it('should include not-yet-flushed events in the download without hiding them', () => { + const store = createStore(); + monitor.publish(bi('a')); + + expect(store.sectionEvents('bi')).toHaveLength(1); + expect(store.sectionCount('bi')).toEqual({ captured: 1, droppedOldest: 0 }); + + flush(); + expect(names(store)).toEqual(['a']); + }); + + it("should export only a section's own events, oldest first", () => { + const store = createStore(); + monitor.publish(bi('first')); + monitor.publish(log('a log line')); + monitor.publish(bi('second')); + flush(); + + const events = store.sectionEvents('bi'); + expect(events.map((e) => e.kind)).toEqual(['bi', 'bi']); + expect(events[0].sequence).toBeLessThan(events[1].sequence); + expect(store.sectionEvents('logging')).toHaveLength(1); + expect(store.sectionEvents('scenario')).toHaveLength(0); + }); + + it('should filter each section on its own, ignoring case', () => { + const store = createStore(); + monitor.publish(bi('theme_changed')); + monitor.publish(bi('sidebar_toggled')); + monitor.publish(log('Careful now')); + flush(); + + store.setText('bi', 'THEME'); + expect(store.filtered().bi).toHaveLength(1); + // Other sections are untouched by BI's filter. + expect(store.filtered().logging).toHaveLength(1); + expect(store.isFiltering('bi')).toBe(true); + expect(store.isFiltering('logging')).toBe(false); + + store.addFilter('logging', { + path: 'level', + operator: 'equals', + value: 'error' + }); + expect(store.filtered().logging).toHaveLength(0); + expect(store.filtered().bi).toHaveLength(1); + + store.removeFilter('logging', store.filters().logging.fields[0].id); + expect(store.filtered().logging).toHaveLength(1); + + store.clearFilters('bi'); + expect(store.filtered().bi).toHaveLength(2); + }); + + it('should suggest filter fields from each section separately', () => { + const store = createStore(); + monitor.publish(bi('a')); + monitor.publish(log('b')); + flush(); + + expect(store.knownPaths().logging).toContain('level'); + expect(store.knownPaths().bi).not.toContain('level'); + expect(store.knownPaths().bi).toContain('eventName'); + }); + + it('should mark the latest arrivals as new, briefly', () => { + const store = createStore(); + monitor.publish(bi('a')); + flush(); + + const [entry] = store.entries().bi; + expect(store.fresh().has(entry.event.sequence)).toBe(true); + jest.advanceTimersByTime(3000); + expect(store.fresh().size).toBe(0); + }); + + it('should keep going after an event it cannot prepare', () => { + const store = createStore(); + const payload: Record<string, unknown> = { eventName: 'x' }; + Object.defineProperty(payload, 'boom', { + enumerable: true, + get: () => { + throw new Error('bad getter'); + } + }); + // Delivered as-is, bypassing the monitor's copy, to simulate a bad payload. + (store as unknown as { receive(e: unknown): void }).receive({ + ...bi('x'), + payload, + sequence: 99, + capturedAt: '' + }); + monitor.publish(bi('fine')); + flush(); + + expect(store.entries().bi).toHaveLength(2); + expect(store.entries().bi[1].renderError).toBe('bad getter'); + }); + + it('should stop observing when destroyed', () => { + createStore(); + expect(monitor.observed).toBe(true); + + destroy(); + expect(monitor.observed).toBe(false); + }); +}); diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-store/cps-diagnostics-store.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-store/cps-diagnostics-store.ts new file mode 100644 index 000000000..036d630a5 --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics-store/cps-diagnostics-store.ts @@ -0,0 +1,334 @@ +import { + computed, + DestroyRef, + inject, + Injectable, + signal +} from '@angular/core'; +import { CpsTelemetryMonitor, CpsTelemetryObservedEvent } from 'cps-telemetry'; +import { + CPS_TELEMETRY_DIAGNOSTICS_CONFIG, + CpsDiagnosticsEntry, + CpsDiagnosticsFieldFilter, + CpsDiagnosticsFilterState, + CpsDiagnosticsSectionId, + CpsTelemetryDiagnosticsConfig +} from '../cps-diagnostics.models/cps-diagnostics.models'; +import { + cpsDiagnosticsSectionOf, + cpsFlattenEvent, + cpsKnownPaths, + cpsMatchesFilters +} from '../cps-diagnostics-filter/cps-diagnostics-filter'; +import { cpsSafeStringify } from '../cps-diagnostics-export/cps-diagnostics-export'; + +/** Library defaults, applied under whatever the application configures. */ +export const CPS_DEFAULT_DIAGNOSTICS_CONFIG: Omit< + CpsTelemetryDiagnosticsConfig, + 'shortcuts' +> = { + enabled: true, + maxEventsPerSection: 500, + maxPayloadCharsInView: 256 * 1024 +}; + +/** How often arriving events reach the screen, in milliseconds. */ +export const CPS_DIAGNOSTICS_FLUSH_MS = 250; +/** How long a row counts as new, in milliseconds. */ +const FRESH_MS = 3000; + +export const CPS_DIAGNOSTICS_SECTIONS: readonly CpsDiagnosticsSectionId[] = [ + 'bi', + 'scenario', + 'logging' +]; + +type PerSection<T> = Record<CpsDiagnosticsSectionId, T>; + +function perSection<T>(make: () => T): PerSection<T> { + return { bi: make(), scenario: make(), logging: make() }; +} + +/** + * The popup's captured history. Provided by the popup itself, so it exists + * exactly as long as the popup is open: created empty on open, subscribed + * to {@link CpsTelemetryMonitor} for its lifetime, and discarded on close. + * + * Events arrive whenever the application sends them; they are batched and + * reach the screen at most every {@link CPS_DIAGNOSTICS_FLUSH_MS}, so a + * burst causes one re-render rather than hundreds. Each section keeps at + * most `maxEventsPerSection`, newest first, and counts what it dropped. + */ +@Injectable() +export class CpsDiagnosticsStore { + private readonly monitor = inject(CpsTelemetryMonitor); + private readonly config: CpsTelemetryDiagnosticsConfig = { + ...CPS_DEFAULT_DIAGNOSTICS_CONFIG, + shortcuts: [], + ...inject(CPS_TELEMETRY_DIAGNOSTICS_CONFIG, { optional: true }) + }; + + /** Newest first. Written on every flush, paused or not. */ + private readonly buffers = perSection<CpsDiagnosticsEntry[]>(() => []); + private readonly droppedCount = perSection(() => 0); + private pending: CpsDiagnosticsEntry[] = []; + private flushTimer?: ReturnType<typeof setTimeout>; + private freshTimer?: ReturnType<typeof setTimeout>; + + /** When capturing began: the popup opening, or the last clear. */ + readonly startedAt = signal(new Date().toISOString()); + + /** What the popup shows: the buffers as of the last unpaused flush. */ + readonly entries = signal<PerSection<readonly CpsDiagnosticsEntry[]>>( + perSection(() => []) + ); + + readonly dropped = signal<PerSection<number>>(perSection(() => 0)); + + /** Freezes what is shown. Capturing carries on underneath. */ + readonly paused = signal(false); + /** Events captured while paused and not yet shown. */ + readonly newWhilePaused = signal(0); + + /** + * Events received per section since opening or the last clear — keeps + * rising after the retention cap, unlike the section lengths. + */ + readonly received = signal<PerSection<number>>(perSection(() => 0)); + + /** Sequence numbers from the latest flush, for a "new" marker. */ + readonly fresh = signal<ReadonlySet<number>>(new Set()); + + /** Each section's own search text and field filters. */ + readonly filters = signal<PerSection<CpsDiagnosticsFilterState>>( + perSection(emptyFilters) + ); + + /** Entries matching each section's own filters. */ + readonly filtered = computed<PerSection<readonly CpsDiagnosticsEntry[]>>( + () => { + const entries = this.entries(); + const filters = this.filters(); + return perSectionFrom((id) => + isFiltering(filters[id]) + ? entries[id].filter((e) => cpsMatchesFilters(e.fields, filters[id])) + : entries[id] + ); + } + ); + + /** Field paths seen so far in each section, for filter suggestions. */ + readonly knownPaths = computed<PerSection<string[]>>(() => { + const entries = this.entries(); + return perSectionFrom((id) => + cpsKnownPaths(entries[id].map((e) => e.fields)) + ); + }); + + readonly total = computed(() => { + const entries = this.entries(); + return CPS_DIAGNOSTICS_SECTIONS.reduce( + (n, id) => n + entries[id].length, + 0 + ); + }); + + constructor() { + const subscription = this.monitor.events$.subscribe((event) => + this.receive(event) + ); + inject(DestroyRef).onDestroy(() => { + subscription.unsubscribe(); + clearTimeout(this.flushTimer); + clearTimeout(this.freshTimer); + }); + } + + /** Empties every section. Capturing continues. */ + clear(): void { + for (const id of CPS_DIAGNOSTICS_SECTIONS) { + this.buffers[id] = []; + this.droppedCount[id] = 0; + } + this.pending = []; + this.startedAt.set(new Date().toISOString()); + this.newWhilePaused.set(0); + this.received.set(perSection(() => 0)); + this.publish(); + } + + setPaused(paused: boolean): void { + this.paused.set(paused); + if (!paused) { + this.newWhilePaused.set(0); + this.publish(); + } + } + + setText(section: CpsDiagnosticsSectionId, text: string): void { + this.updateFilters(section, (f) => ({ ...f, text })); + } + + addFilter( + section: CpsDiagnosticsSectionId, + filter: Omit<CpsDiagnosticsFieldFilter, 'id'> + ): void { + this.updateFilters(section, (f) => ({ + ...f, + fields: [...f.fields, { ...filter, id: newId() }] + })); + } + + removeFilter(section: CpsDiagnosticsSectionId, id: string): void { + this.updateFilters(section, (f) => ({ + ...f, + fields: f.fields.filter((x) => x.id !== id) + })); + } + + clearFilters(section: CpsDiagnosticsSectionId): void { + this.updateFilters(section, emptyFilters); + } + + /** Whether a section has any filter or search text active. */ + isFiltering(section: CpsDiagnosticsSectionId): boolean { + return isFiltering(this.filters()[section]); + } + + /** + * Every event captured in one section: what is shown, plus anything held + * back while paused or still waiting for the next flush. Oldest first. + */ + sectionEvents(section: CpsDiagnosticsSectionId): CpsTelemetryObservedEvent[] { + return [ + ...this.buffers[section].map((e) => e.event), + ...this.pending.filter((e) => e.section === section).map((e) => e.event) + ].sort((a, b) => a.sequence - b.sequence); + } + + /** One section's totals for its export, counted like {@link sectionEvents}. */ + sectionCount(section: CpsDiagnosticsSectionId): { + captured: number; + droppedOldest: number; + } { + return { + captured: + this.buffers[section].length + + this.pending.filter((e) => e.section === section).length, + droppedOldest: this.droppedCount[section] + }; + } + + private updateFilters( + section: CpsDiagnosticsSectionId, + change: (f: CpsDiagnosticsFilterState) => CpsDiagnosticsFilterState + ): void { + this.filters.update((all) => ({ ...all, [section]: change(all[section]) })); + } + + /** + * Called for every observed event. Never throws: an error here would + * surface asynchronously in the application, since RxJS reports + * subscriber errors on its own. + */ + private receive(event: CpsTelemetryObservedEvent): void { + try { + this.pending.push(this.prepare(event)); + this.flushTimer ??= setTimeout( + () => this.flush(), + CPS_DIAGNOSTICS_FLUSH_MS + ); + } catch { + // Nothing to show for this one; later events are unaffected. + } + } + + private prepare(event: CpsTelemetryObservedEvent): CpsDiagnosticsEntry { + const section = cpsDiagnosticsSectionOf(event.kind); + try { + return { + event, + section, + fields: cpsFlattenEvent(event), + sizeChars: cpsSafeStringify(event.payload).length + }; + } catch (error) { + return { + event, + section, + fields: [], + sizeChars: 0, + renderError: error instanceof Error ? error.message : String(error) + }; + } + } + + private flush(): void { + this.flushTimer = undefined; + const arrived = this.drain(); + if (arrived.length === 0) { + return; + } + this.received.update((counts) => { + const next = { ...counts }; + for (const entry of arrived) { + next[entry.section]++; + } + return next; + }); + if (this.paused()) { + this.newWhilePaused.update((n) => n + arrived.length); + return; + } + this.publish(arrived); + } + + /** Moves pending events into the buffers. Returns what moved. */ + private drain(): CpsDiagnosticsEntry[] { + const arrived = this.pending; + this.pending = []; + const max = Math.max(1, this.config.maxEventsPerSection); + for (const entry of arrived) { + const buffer = this.buffers[entry.section]; + buffer.unshift(entry); + if (buffer.length > max) { + this.droppedCount[entry.section] += buffer.length - max; + buffer.length = max; + } + } + return arrived; + } + + private publish(arrived: readonly CpsDiagnosticsEntry[] = []): void { + this.entries.set(perSectionFrom((id) => [...this.buffers[id]])); + this.dropped.set({ ...this.droppedCount }); + this.fresh.set(new Set(arrived.map((e) => e.event.sequence))); + clearTimeout(this.freshTimer); + if (arrived.length) { + this.freshTimer = setTimeout(() => this.fresh.set(new Set()), FRESH_MS); + } + } +} + +function perSectionFrom<T>( + make: (id: CpsDiagnosticsSectionId) => T +): PerSection<T> { + return { + bi: make('bi'), + scenario: make('scenario'), + logging: make('logging') + }; +} + +function emptyFilters(): CpsDiagnosticsFilterState { + return { text: '', fields: [] }; +} + +function isFiltering(filters: CpsDiagnosticsFilterState): boolean { + return !!filters.text.trim() || filters.fields.length > 0; +} + +let idCounter = 0; +function newId(): string { + return `f${++idCounter}`; +} diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics.models/cps-diagnostics.models.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics.models/cps-diagnostics.models.ts new file mode 100644 index 000000000..ae2188f81 --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics.models/cps-diagnostics.models.ts @@ -0,0 +1,141 @@ +import { InjectionToken } from '@angular/core'; +import { CpsTelemetryObservedEvent } from 'cps-telemetry'; + +/** + * The three sections of the diagnostics popup. + * + * @group Types + */ +export type CpsDiagnosticsSectionId = 'bi' | 'scenario' | 'logging'; + +/** + * A key combination that opens the diagnostics popup. + * + * Matched on `KeyboardEvent.code` — the physical key — so it works on every + * keyboard layout, and isn't affected by what Option or Shift produce. + * A modifier left out must be *up*, so extra modifiers never match. + * + * @group Interfaces + */ +export interface CpsDiagnosticsShortcut { + /** `KeyboardEvent.code`, e.g. `'Digit8'` or `'KeyD'`. */ + code: string; + ctrl?: boolean; + alt?: boolean; + shift?: boolean; + meta?: boolean; + + /** How the shortcut is written for people, e.g. `'⇧⌥⌘8'`. */ + label?: string; +} + +/** + * Configuration for {@link provideCpsTelemetryDiagnostics}. + * + * @group Interfaces + */ +export interface CpsTelemetryDiagnosticsConfig { + /** + * Whether the popup can be opened at all. A function is read once, at + * startup — use it to limit access to, say, support staff. + */ + enabled: boolean | (() => boolean); + + /** + * Shortcuts that open the popup. An empty array disables the keyboard, + * leaving {@link CpsTelemetryDiagnosticsService.open} as the only way in. + */ + shortcuts: readonly CpsDiagnosticsShortcut[]; + + /** Events kept per section; the oldest are dropped beyond this. */ + maxEventsPerSection: number; + + /** + * Payloads whose JSON is longer than this many characters are shown + * truncated. The download always contains them in full. + * + * Characters (UTF-16 code units, JavaScript's `length`), not bytes: the + * limit protects rendering, which costs by the length of the text. + */ + maxPayloadCharsInView: number; +} + +/** @group Tokens */ +export const CPS_TELEMETRY_DIAGNOSTICS_CONFIG = + new InjectionToken<CpsTelemetryDiagnosticsConfig>( + 'CPS_TELEMETRY_DIAGNOSTICS_CONFIG' + ); + +/** A flattened leaf of an event, used for filtering. */ +export interface CpsDiagnosticsField { + /** Exact path, e.g. `steps[2].name`. */ + path: string; + + /** Path with array indexes removed, e.g. `steps[].name`. */ + pattern: string; + + /** The value as text. */ + value: string; +} + +/** One captured event, prepared once for display and filtering. */ +export interface CpsDiagnosticsEntry { + event: CpsTelemetryObservedEvent; + section: CpsDiagnosticsSectionId; + fields: readonly CpsDiagnosticsField[]; + /** Length of the payload's JSON, in characters (UTF-16 code units). */ + sizeChars: number; + /** Set when the event could not be prepared for display. */ + renderError?: string; +} + +/** + * How a field filter compares. + * + * @group Types + */ +export type CpsDiagnosticsFilterOperator = + 'contains' | 'equals' | 'not-contains' | 'exists' | 'missing'; + +/** @group Interfaces */ +export interface CpsDiagnosticsFieldFilter { + id: string; + path: string; + operator: CpsDiagnosticsFilterOperator; + value: string; +} + +/** @group Interfaces */ +export interface CpsDiagnosticsFilterState { + /** Free text, matched against every value and path. */ + text: string; + fields: readonly CpsDiagnosticsFieldFilter[]; +} + +/** + * A downloaded JSON file: one section's captured events. + * + * `events` holds every event captured in that section, oldest first, + * regardless of the filters on screen. `activeFilters` records that + * section's filters, for context; they were not applied to `events`. + * + * @group Interfaces + */ +export interface CpsDiagnosticsExport { + format: 'cps-telemetry-diagnostics'; + formatVersion: 1; + section: CpsDiagnosticsSectionId; + exportedAt: string; + capture: { startedAt: string; endedAt: string; paused: boolean }; + app: { + application: string; + environment: string; + version: string; + eventNamespace: string; + }; + session: { sessionId?: string; userId?: string }; + userAgent: string; + activeFilters: CpsDiagnosticsFilterState; + count: { captured: number; droppedOldest: number }; + events: CpsTelemetryObservedEvent[]; +} diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics.providers/cps-diagnostics.providers.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics.providers/cps-diagnostics.providers.ts new file mode 100644 index 000000000..cc22b1cae --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-diagnostics.providers/cps-diagnostics.providers.ts @@ -0,0 +1,60 @@ +import { + EnvironmentProviders, + inject, + makeEnvironmentProviders, + provideAppInitializer +} from '@angular/core'; +import { + CPS_TELEMETRY_DIAGNOSTICS_CONFIG, + CpsTelemetryDiagnosticsConfig +} from '../cps-diagnostics.models/cps-diagnostics.models'; +import { CPS_DEFAULT_DIAGNOSTICS_CONFIG } from '../cps-diagnostics-store/cps-diagnostics-store'; +import { CPS_DEFAULT_DIAGNOSTICS_SHORTCUTS } from '../cps-diagnostics-shortcut/cps-diagnostics-shortcut'; +import { CpsTelemetryDiagnosticsService } from '../cps-telemetry-diagnostics.service/cps-telemetry-diagnostics.service'; + +/** + * Adds the telemetry diagnostics popup: an in-app window showing, live, + * every BI, scenario and log event this app hands to its destinations. + * + * Opens with ⇧⌥⌘8 (macOS) or Ctrl+Alt+Shift+8 (Windows, Linux) by + * default, in every environment. Needs cps-ui-kit set up in the app, as + * for any cps-ui-kit dialog. + * + * @example + * ```typescript + * import { provideCpsTelemetryDiagnostics } from 'cps-telemetry/diagnostics'; + * + * providers: [ + * provideCpsTelemetry({ application: 'my-app', environment, version }), + * provideCpsTelemetryDiagnostics() + * ] + * + * // Or a different key, or access limited to some users: + * provideCpsTelemetryDiagnostics({ + * shortcuts: [{ code: 'KeyD', ctrl: true, alt: true, shift: true }], + * enabled: () => inject(AuthService).isSupportStaff() + * }) + * ``` + * + * @param config overrides merged over the defaults + * @returns providers for the popup and its shortcut + * + * @group Utils + */ +export function provideCpsTelemetryDiagnostics( + config: Partial<CpsTelemetryDiagnosticsConfig> = {} +): EnvironmentProviders { + return makeEnvironmentProviders([ + { + provide: CPS_TELEMETRY_DIAGNOSTICS_CONFIG, + useValue: { + ...CPS_DEFAULT_DIAGNOSTICS_CONFIG, + shortcuts: CPS_DEFAULT_DIAGNOSTICS_SHORTCUTS, + ...config + } + }, + provideAppInitializer(() => { + inject(CpsTelemetryDiagnosticsService).install(); + }) + ]); +} diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-telemetry-diagnostics.service/cps-telemetry-diagnostics.service.spec.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-telemetry-diagnostics.service/cps-telemetry-diagnostics.service.spec.ts new file mode 100644 index 000000000..78ff1caf4 --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-telemetry-diagnostics.service/cps-telemetry-diagnostics.service.spec.ts @@ -0,0 +1,194 @@ +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Subject } from 'rxjs'; +import { CpsDialogService } from 'cps-ui-kit'; +import { + CPS_TELEMETRY_DIAGNOSTICS_CONFIG, + CpsTelemetryDiagnosticsConfig +} from '../cps-diagnostics.models/cps-diagnostics.models'; +import { CpsDiagnosticsDialogComponent } from '../cps-diagnostics-dialog/cps-diagnostics-dialog.component'; +import { CpsTelemetryDiagnosticsService } from './cps-telemetry-diagnostics.service'; + +/** Stands in for CpsDialogService, recording what was opened. */ +class FakeDialogs { + readonly opened: { component: unknown; config: Record<string, unknown> }[] = + []; + + readonly destroyed = new Subject<void>(); + containsFocus = false; + readonly instance = { + containsFocus: () => this.containsFocus, + focusSearch: jest.fn() + }; + + readonly close = jest.fn(() => this.destroyed.next()); + + open(component: unknown, config: Record<string, unknown>) { + this.opened.push({ component, config }); + return { + componentInstance: this.instance, + onDestroy: this.destroyed.asObservable(), + close: this.close + }; + } +} + +const SHORTCUT = { + code: 'Digit8', + ctrlKey: true, + altKey: true, + shiftKey: true +}; + +type KeyInit = ConstructorParameters<typeof KeyboardEvent>[1]; + +function press(init: KeyInit = SHORTCUT): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + ...init, + bubbles: true, + cancelable: true + }); + document.body.dispatchEvent(event); + return event; +} + +describe('CpsTelemetryDiagnosticsService', () => { + let dialogs: FakeDialogs; + + function create( + config: Partial<CpsTelemetryDiagnosticsConfig> = {}, + platform = 'browser' + ): CpsTelemetryDiagnosticsService { + TestBed.resetTestingModule(); + dialogs = new FakeDialogs(); + TestBed.configureTestingModule({ + providers: [ + { provide: CpsDialogService, useValue: dialogs }, + { provide: PLATFORM_ID, useValue: platform }, + { provide: CPS_TELEMETRY_DIAGNOSTICS_CONFIG, useValue: config } + ] + }); + return TestBed.inject(CpsTelemetryDiagnosticsService); + } + + afterEach(() => { + TestBed.resetTestingModule(); + jest.restoreAllMocks(); + }); + + it('should open the popup, non-modal and named, on the default shortcut', () => { + create().install(); + + const event = press(); + + expect(dialogs.opened).toHaveLength(1); + expect(dialogs.opened[0].component).toBe(CpsDiagnosticsDialogComponent); + expect(dialogs.opened[0].config).toMatchObject({ + modal: false, + ariaLabel: 'Telemetry diagnostics', + closeOnEscape: true + }); + expect(event.defaultPrevented).toBe(true); + expect(TestBed.inject(CpsTelemetryDiagnosticsService).isOpen()).toBe(true); + }); + + it('should also open on the macOS shortcut', () => { + create().install(); + press({ code: 'Digit8', metaKey: true, altKey: true, shiftKey: true }); + expect(dialogs.opened).toHaveLength(1); + }); + + it('should ignore ordinary typing and near misses', () => { + create().install(); + + press({ code: 'Digit8' }); + press({ code: 'Digit8', ctrlKey: true, shiftKey: true }); + press({ code: 'KeyD', ctrlKey: true, altKey: true, shiftKey: true }); + + expect(dialogs.opened).toHaveLength(0); + }); + + it('should bring focus back to an open popup, and close it from inside', () => { + create().install(); + press(); + + dialogs.containsFocus = false; + press(); + expect(dialogs.instance.focusSearch).toHaveBeenCalledTimes(1); + expect(dialogs.close).not.toHaveBeenCalled(); + + dialogs.containsFocus = true; + press(); + expect(dialogs.close).toHaveBeenCalledTimes(1); + expect(TestBed.inject(CpsTelemetryDiagnosticsService).isOpen()).toBe(false); + }); + + it('should open fresh after the previous popup was destroyed', () => { + create().install(); + press(); + dialogs.destroyed.next(); + + press(); + expect(dialogs.opened).toHaveLength(2); + }); + + it('should use custom shortcuts instead of the defaults', () => { + create({ + shortcuts: [{ code: 'KeyD', ctrl: true, alt: true, shift: true }] + }).install(); + + press(); + expect(dialogs.opened).toHaveLength(0); + press({ code: 'KeyD', ctrlKey: true, altKey: true, shiftKey: true }); + expect(dialogs.opened).toHaveLength(1); + }); + + it('should not listen with no shortcuts, but still open on request', () => { + const service = create({ shortcuts: [] }); + service.install(); + + press(); + expect(dialogs.opened).toHaveLength(0); + + service.open(); + expect(dialogs.opened).toHaveLength(1); + }); + + it.each([ + ['disabled', { enabled: false }], + ['disabled by a function', { enabled: () => false }] + ])('should do nothing when %s', (_label, config) => { + const service = create(config); + service.install(); + + press(); + service.open(); + expect(dialogs.opened).toHaveLength(0); + }); + + it('should treat a throwing enabled() as disabled', () => { + jest.spyOn(console, 'error').mockImplementation(() => undefined); + const service = create({ + enabled: () => { + throw new Error('auth not ready'); + } + }); + service.install(); + service.open(); + expect(dialogs.opened).toHaveLength(0); + }); + + it('should not listen during a server-side render', () => { + create({}, 'server').install(); + press(); + expect(dialogs.opened).toHaveLength(0); + }); + + it('should stop listening when the application is destroyed', () => { + create().install(); + TestBed.resetTestingModule(); + + press(); + expect(dialogs.opened).toHaveLength(0); + }); +}); diff --git a/projects/cps-telemetry/diagnostics/src/lib/cps-telemetry-diagnostics.service/cps-telemetry-diagnostics.service.ts b/projects/cps-telemetry/diagnostics/src/lib/cps-telemetry-diagnostics.service/cps-telemetry-diagnostics.service.ts new file mode 100644 index 000000000..672238b11 --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/lib/cps-telemetry-diagnostics.service/cps-telemetry-diagnostics.service.ts @@ -0,0 +1,167 @@ +import { DOCUMENT, isPlatformBrowser } from '@angular/common'; +import { + DestroyRef, + inject, + Injectable, + NgZone, + PLATFORM_ID, + signal +} from '@angular/core'; +import { CpsDialogRef, CpsDialogService } from 'cps-ui-kit'; +import { + CPS_TELEMETRY_DIAGNOSTICS_CONFIG, + CpsTelemetryDiagnosticsConfig +} from '../cps-diagnostics.models/cps-diagnostics.models'; +import { CPS_DEFAULT_DIAGNOSTICS_CONFIG } from '../cps-diagnostics-store/cps-diagnostics-store'; +import { + CPS_DEFAULT_DIAGNOSTICS_SHORTCUTS, + cpsMatchesShortcut +} from '../cps-diagnostics-shortcut/cps-diagnostics-shortcut'; +import { CpsDiagnosticsDialogComponent } from '../cps-diagnostics-dialog/cps-diagnostics-dialog.component'; +import { + cpsSafe, + cpsSafeVoid +} from '../cps-diagnostics-internal.util/cps-diagnostics-internal.util'; + +/** + * Opens and closes the telemetry diagnostics popup, and listens for its + * keyboard shortcut once {@link provideCpsTelemetryDiagnostics} installs it. + * + * The popup is non-modal: the application stays usable while events + * arrive. With the popup open, the shortcut returns focus to it from the + * application, and closes it when focus is already inside. + * + * @example + * ```typescript + * // From an app's own "Support" menu, when the shortcut is disabled: + * inject(CpsTelemetryDiagnosticsService).open(); + * ``` + * + * @group Services + */ +@Injectable({ providedIn: 'root' }) +export class CpsTelemetryDiagnosticsService { + private readonly dialogs = inject(CpsDialogService); + private readonly document = inject(DOCUMENT); + private readonly zone = inject(NgZone); + private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); + private readonly destroyRef = inject(DestroyRef); + private readonly config: CpsTelemetryDiagnosticsConfig = { + ...CPS_DEFAULT_DIAGNOSTICS_CONFIG, + shortcuts: CPS_DEFAULT_DIAGNOSTICS_SHORTCUTS, + ...inject(CPS_TELEMETRY_DIAGNOSTICS_CONFIG, { optional: true }) + }; + + private readonly enabled = cpsSafe( + 'enabled', + () => + typeof this.config.enabled === 'function' + ? this.config.enabled() + : this.config.enabled, + false + ); + + private ref?: CpsDialogRef<CpsDiagnosticsDialogComponent>; + private installed = false; + + /** Whether the popup is currently open. */ + readonly isOpen = signal(false); + + /** + * Starts listening for the shortcut. Called by + * {@link provideCpsTelemetryDiagnostics}; does nothing on the server, + * when disabled, with no shortcuts, or when already listening. + */ + install(): void { + if ( + this.installed || + !this.isBrowser || + !this.enabled || + this.config.shortcuts.length === 0 + ) { + return; + } + this.installed = true; + + const listener = (event: KeyboardEvent) => + cpsSafeVoid('shortcut', () => { + if (!this.config.shortcuts.some((s) => cpsMatchesShortcut(event, s))) { + return; + } + event.preventDefault(); + this.zone.run(() => this.onShortcut()); + }); + + // Capture phase, so it works even where a component stops propagation. + // Outside Angular's zone, so ordinary typing triggers no change detection. + this.zone.runOutsideAngular(() => + this.document.addEventListener('keydown', listener, true) + ); + this.destroyRef.onDestroy(() => { + this.document.removeEventListener('keydown', listener, true); + this.close(); + }); + } + + /** Opens the popup, or moves focus into it when already open. */ + open(): void { + if (!this.enabled || !this.isBrowser) { + return; + } + if (this.ref) { + this.ref.componentInstance?.focusSearch(); + return; + } + cpsSafeVoid('open', () => { + const ref: CpsDialogRef<CpsDiagnosticsDialogComponent> = + this.dialogs.open(CpsDiagnosticsDialogComponent, { + headerTitle: 'Telemetry diagnostics', + ariaLabel: 'Telemetry diagnostics', + modal: false, + draggable: true, + resizable: true, + maximizable: true, + closeOnEscape: true, + // Docked right at half the screen, so the application stays + // visible and clickable beside it; resizable, and maximizable + // for a closer look. + position: 'right', + width: 'min(64rem, 50vw)', + height: '88vh', + minWidth: 'min(22rem, 96vw)', + autoFocus: '.cps-diagnostics-search input' + }); + this.ref = ref; + this.isOpen.set(true); + ref.onDestroy.subscribe(() => { + if (this.ref === ref) { + this.ref = undefined; + this.isOpen.set(false); + } + }); + }); + } + + /** Closes the popup, discarding what it captured. */ + close(): void { + cpsSafeVoid('close', () => this.ref?.close()); + } + + /** + * What the shortcut does: open when closed; with the popup open, close + * it if focus is inside, otherwise bring focus back to it. + */ + toggle(): void { + if (!this.ref) { + this.open(); + } else if (this.ref.componentInstance?.containsFocus()) { + this.close(); + } else { + this.ref.componentInstance?.focusSearch(); + } + } + + private onShortcut(): void { + this.toggle(); + } +} diff --git a/projects/cps-telemetry/diagnostics/src/public-api.ts b/projects/cps-telemetry/diagnostics/src/public-api.ts new file mode 100644 index 000000000..48fe491be --- /dev/null +++ b/projects/cps-telemetry/diagnostics/src/public-api.ts @@ -0,0 +1,21 @@ +/* + * Public API Surface of cps-telemetry/diagnostics + * + * An in-app popup showing, live, every event this app hands to its + * telemetry destinations. Needs cps-ui-kit; see DESIGN.md §3. + */ + +export { + CPS_TELEMETRY_DIAGNOSTICS_CONFIG, + CpsDiagnosticsExport, + CpsDiagnosticsFieldFilter, + CpsDiagnosticsFilterOperator, + CpsDiagnosticsFilterState, + CpsDiagnosticsSectionId, + CpsDiagnosticsShortcut, + CpsTelemetryDiagnosticsConfig +} from './lib/cps-diagnostics.models/cps-diagnostics.models'; +export { CPS_DEFAULT_DIAGNOSTICS_SHORTCUTS } from './lib/cps-diagnostics-shortcut/cps-diagnostics-shortcut'; +export { CPS_DEFAULT_DIAGNOSTICS_CONFIG } from './lib/cps-diagnostics-store/cps-diagnostics-store'; +export { provideCpsTelemetryDiagnostics } from './lib/cps-diagnostics.providers/cps-diagnostics.providers'; +export { CpsTelemetryDiagnosticsService } from './lib/cps-telemetry-diagnostics.service/cps-telemetry-diagnostics.service'; diff --git a/projects/cps-telemetry/diagnostics/tsconfig.lib.json b/projects/cps-telemetry/diagnostics/tsconfig.lib.json new file mode 100644 index 000000000..04a9759ff --- /dev/null +++ b/projects/cps-telemetry/diagnostics/tsconfig.lib.json @@ -0,0 +1,25 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../out-tsc/lib-diagnostics", + "rootDir": "../../..", + "lib": ["ES2022", "DOM"], + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts"], + "angularCompilerOptions": { + "extendedDiagnostics": { + "checks": { + "nullishCoalescingNotNullable": "suppress", + "optionalChainNotNullable": "suppress" + } + } + } +} diff --git a/projects/cps-telemetry/diagnostics/tsconfig.spec.json b/projects/cps-telemetry/diagnostics/tsconfig.spec.json new file mode 100644 index 000000000..a78ee905c --- /dev/null +++ b/projects/cps-telemetry/diagnostics/tsconfig.spec.json @@ -0,0 +1,11 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../out-tsc/spec-diagnostics", + "rootDir": "../../..", + "types": ["jest"], + "resolveJsonModule": true + }, + "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/projects/cps-telemetry/ng-package.json b/projects/cps-telemetry/ng-package.json new file mode 100644 index 000000000..1ce74ad54 --- /dev/null +++ b/projects/cps-telemetry/ng-package.json @@ -0,0 +1,7 @@ +{ + "$schema": "../../node_modules/ng-packagr/ng-package.schema.json", + "dest": "../../dist/cps-telemetry", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/cps-telemetry/package.json b/projects/cps-telemetry/package.json new file mode 100644 index 000000000..a38a07a55 --- /dev/null +++ b/projects/cps-telemetry/package.json @@ -0,0 +1,38 @@ +{ + "name": "cps-telemetry", + "version": "0.1.0", + "description": "Reusable Angular telemetry layer: application logs, scenario health telemetry and business/UX events, with an optional AWS CloudWatch RUM sink.", + "peerDependencies": { + "@angular/common": "^22.1.3", + "@angular/core": "^22.1.3", + "@angular/animations": "^22.1.3", + "@angular/forms": "^22.1.3", + "aws-rum-web": "^3.2.1", + "cps-ui-kit": "^22.0.0", + "rxjs": "^7.8.2" + }, + "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + }, + "@angular/forms": { + "optional": true + }, + "aws-rum-web": { + "optional": true + }, + "cps-ui-kit": { + "optional": true + } + }, + "repository": { + "type": "git", + "url": "https://github.com/AbsaOSS/cps-shared-ui" + }, + "homepage": "https://github.com/AbsaOSS/cps-shared-ui", + "license": "Apache License 2.0", + "dependencies": { + "tslib": "^2.8.1" + }, + "sideEffects": false +} diff --git a/projects/cps-telemetry/rum/ng-package.json b/projects/cps-telemetry/rum/ng-package.json new file mode 100644 index 000000000..e09fb3fd0 --- /dev/null +++ b/projects/cps-telemetry/rum/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-entrypoint.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/cps-telemetry/rum/src/lib/cps-rum-credentials/cps-rum-credentials.ts b/projects/cps-telemetry/rum/src/lib/cps-rum-credentials/cps-rum-credentials.ts new file mode 100644 index 000000000..13e187e2c --- /dev/null +++ b/projects/cps-telemetry/rum/src/lib/cps-rum-credentials/cps-rum-credentials.ts @@ -0,0 +1,307 @@ +import { InjectionToken } from '@angular/core'; + +/** + * Short-lived AWS credentials for the RUM data plane. + * + * @group Interfaces + */ +export interface CpsRumCredentials { + accessKeyId: string; + secretAccessKey: string; + sessionToken: string; + + /** ISO-8601 expiry. Used to schedule a refresh before the credentials lapse. */ + expiration: string; +} + +/** + * Mirrors `aws-rum-web`'s own `AwsCredentialIdentity | AwsCredentialIdentityProvider` + * union — used only by {@link CpsRumAppMonitorConfig.clientBuilder}. + */ +type ClientBuilderCredentials = + | { accessKeyId: string; secretAccessKey: string; sessionToken?: string } + | (() => Promise<{ + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + }>); + +/** + * Settings for the RUM app monitor, mapped onto the underlying `aws-rum-web` + * SDK's own `Config`. Every optional field left unset falls back to a + * default: `allowCookies`, `enableXRay`, `endpoint` and `telemetries` fall + * back to this library's own override; every other field falls back to the + * SDK's own default (see `buildRumConfig()`). + * + * Matches the SDK's `Config` field for field, with three omissions: + * + * - `endpointUrl` — always recomputed by the SDK from `endpoint`; setting it + * independently has no effect. + * - `identityPoolId`, `guestRoleArn` — the SDK's own Cognito guest-identity + * credential flow. AWS credentials come from a backend-vended + * `CpsRumCredentialsProvider` instead (see + * `CpsRumTelemetrySink.applyCredentials`). + * - `sessionId`, `userId` — owned at runtime by `CpsRumTelemetrySink`'s + * `setUserId`, `pinUserId` and `startSession`. + * + * None of the field types below are imported from `aws-rum-web`; every type + * here is hand-written. This keeps `aws-rum-web` an optional peer + * dependency for applications that only use the `broadcast` or `noop` sink. + * + * @group Interfaces + */ +export interface CpsRumAppMonitorConfig { + /** CloudWatch RUM app monitor id. */ + applicationId: string; + + /** AWS region hosting the app monitor, e.g. `eu-west-1`. */ + region: string; + + /** Version string reported with every event. */ + applicationVersion: string; + + // ---- Sampling & session ---- + + /** Fraction of sessions recorded, `0`–`1`. Default `1`. */ + sessionSampleRate?: number; + + /** Session length before a new one begins, in seconds. Default `1800` (30 minutes). */ + sessionLengthSeconds?: number; + + /** + * Hard cap on events recorded per session, across all telemetry. Default + * `200`. See DESIGN.md §7. + */ + sessionEventLimit?: number; + + /** + * Suppresses the `session_start` event the SDK would otherwise emit at the + * start of a session. Default `false`. + */ + suppressSessionStartEvent?: boolean; + + /** Days an anonymous user id is retained in cookies/local storage. Default `30`. */ + userIdRetentionDays?: number; + + /** + * Attributes attached to every event in the session, seeded at + * construction. Default `{}`. `CpsRumTelemetrySink` merges `application`, + * `environment` and `appVersion` on top of this after construction; those + * three keys always win. + */ + sessionAttributes?: Record<string, string | number | boolean>; + + /** + * Attributes attached to every event for the lifetime of the app monitor + * client, independent of session. Default unset. + */ + applicationAttributes?: Record<string, string | number | boolean>; + + // ---- Telemetry collection & tracing ---- + + /** + * Built-in telemetry categories to enable. Defaults to errors, performance + * and same-origin HTTP. The SDK's own default also includes `'replay'` + * (session replay via `rrweb`); this library does not enable it by + * default. + */ + telemetries?: (string | (string | object)[])[]; + + /** + * Custom event plugins to load alongside the built-in telemetry, each + * implementing `aws-rum-web`'s own `Plugin` interface. Default `[]`. + */ + eventPluginsToLoad?: unknown[]; + + /** Whether to emit X-Ray trace ids. Default `true`. */ + enableXRay?: boolean; + + /** Whether to emit W3C trace context ids alongside X-Ray's. Default `false`. */ + enableW3CTraceId?: boolean; + + /** Whether resource-timing events record the resource's URL. Default `true`. */ + recordResourceUrl?: boolean; + + // ---- Dispatch & buffering ---- + + /** Data plane endpoint. Defaults to the regional RUM endpoint. */ + endpoint?: string; + + /** Extra headers sent with every dispatch request. Default unset. */ + headers?: Record<string, string>; + + /** Free-form label reported alongside dispatched events. Default unset. */ + alias?: string; + + /** Maximum events sent per dispatch request. Default `100`. */ + batchLimit?: number; + + /** Interval between automatic dispatches, in milliseconds. Default `5000`. */ + dispatchInterval?: number; + + /** Maximum events held in the in-memory cache awaiting dispatch. Default `1000`. */ + eventCacheSize?: number; + + /** Maximum not-yet-recorded candidate events cached. Default `10`. */ + candidatesCacheSize?: number; + + /** Retries for a failed dispatch request. Default `2`. */ + retries?: number; + + /** Whether unload-time flushes use `navigator.sendBeacon`. Default `true`. */ + useBeacon?: boolean; + + /** + * Whether dispatch requests are SigV4-signed. Default `true`. Signing is + * active whenever credentials are present — which they are here, via + * `setAwsCredentials` from `CpsRumCredentialsProvider` — independent of + * `identityPoolId`; that field only matters for the SDK's own, separate + * Cognito guest-identity flow, which this library does not use. Leave + * this enabled for broker-supplied credentials: disabling it here still + * sends unsigned requests to the same signed-only endpoint and gets + * rejected. Only turn it off if something in front of dispatch (a proxy, + * a different transport) already signs the request itself. + */ + signing?: boolean; + + /** Whether the request body is gzip-compressed before dispatch. Default `{ enabled: true }`. */ + compressionStrategy?: { enabled: boolean }; + + /** + * Overrides the SDK's own `fetch`-based transport. Default unset — the + * SDK uses the global `fetch`. + */ + fetchFunction?: typeof fetch; + + /** + * Overrides how the SDK builds its data-plane HTTP client. An advanced + * escape hatch — see `aws-rum-web`'s own `ClientBuilder` type for the exact + * signature (`(endpoint: URL, region: string, credentials?, compressionStrategy?) => DataPlaneClient`). + * Default unset. + */ + clientBuilder?: ( + endpoint: URL, + region: string, + credentials?: ClientBuilderCredentials, + compressionStrategy?: { enabled: boolean } + ) => unknown; + + // ---- Cookies & privacy ---- + + /** Whether the client may use cookies for session continuity. Default `true`. */ + allowCookies?: boolean; + + /** + * Overrides for the cookies the client sets. Unset fields fall back to the + * SDK's own defaults (`domain` = current hostname, `path` = `/`, + * `sameSite` = `'Strict'`, `secure` = `true`, `unique` = `false`). + */ + cookieAttributes?: { + unique?: boolean; + domain?: string; + path?: string; + sameSite?: string; + secure?: boolean; + }; + + // ---- Page tracking ---- + + /** + * Disables the client's own automatic page-view recording on navigation. + * Pair with `CpsRumTelemetrySink.recordPageView()` to record page ids + * yourself. Default `false`. + */ + disableAutoPageView?: boolean; + + /** How the page id is derived from the URL. Default `'PATH'`. */ + pageIdFormat?: 'PATH' | 'HASH' | 'PATH_AND_HASH'; + + /** Pages recorded, matched against the page id. Default matches every page. */ + pagesToInclude?: RegExp[]; + + /** Pages excluded from recording, matched against the page id. Default `[]`. */ + pagesToExclude?: RegExp[]; + + /** How long, in milliseconds, a route change waits before being considered complete. Default `100`. */ + routeChangeComplete?: number; + + /** Timeout, in milliseconds, before an incomplete route change is abandoned. Default `10000`. */ + routeChangeTimeout?: number; + + // ---- Advanced / escape hatches ---- + + /** + * Label reported as the SDK's own install method. Default is the SDK's + * internal module-install constant; overriding it is rarely useful outside + * of testing the SDK itself. + */ + client?: string; + + /** Release identifier reported with every event, e.g. a build SHA. Default unset. */ + releaseId?: string; + + /** Logs the SDK's own internal diagnostics to the console. Default `false`. */ + debug?: boolean; + + /** Master switch for the RUM client. Default `true`. */ + enableRumClient?: boolean; +} + +/** + * The payload returned by a {@link CpsRumCredentialsProvider}. + * + * @group Interfaces + */ +export interface CpsRumBootstrap { + /** App monitor settings. */ + config: CpsRumAppMonitorConfig; + + /** + * Credentials for the data plane. Omit when the app monitor is configured for + * unauthenticated access. + */ + credentials?: CpsRumCredentials; +} + +/** + * What the RUM sink needs from the host application to start. + * + * Keeps the telemetry layer free of AWS account details — how the app + * monitor id is discovered and how credentials are vended is entirely the + * application's business. + * + * @example + * ```typescript + * @Injectable({ providedIn: 'root' }) + * export class AppRumCredentialsProvider implements CpsRumCredentialsProvider { + * async load(): Promise<CpsRumBootstrap | null> { + * const res = await fetch('/rum/init'); + * if (!res.ok) return null; + * const { enabled, config, credentials } = await res.json(); + * return enabled ? { config, credentials } : null; + * } + * } + * ``` + * + * @group Interfaces + */ +export interface CpsRumCredentialsProvider { + /** + * Resolves the app monitor settings and a set of credentials. + * + * Called once at startup and again shortly before each credential expiry. + * Returning `null` disables RUM for the session — the telemetry layer stays + * fully functional, it simply stops shipping. + * + * @returns the bootstrap payload, or `null` to disable RUM + */ + load(): Promise<CpsRumBootstrap | null>; +} + +/** + * Binds the application's {@link CpsRumCredentialsProvider}. + * + * @group Tokens + */ +export const CPS_RUM_CREDENTIALS_PROVIDER = + new InjectionToken<CpsRumCredentialsProvider>('CPS_RUM_CREDENTIALS_PROVIDER'); diff --git a/projects/cps-telemetry/rum/src/lib/cps-rum-internal.util/cps-rum-internal.util.ts b/projects/cps-telemetry/rum/src/lib/cps-rum-internal.util/cps-rum-internal.util.ts new file mode 100644 index 000000000..d4939f400 --- /dev/null +++ b/projects/cps-telemetry/rum/src/lib/cps-rum-internal.util/cps-rum-internal.util.ts @@ -0,0 +1,119 @@ +import { isPlatformBrowser } from '@angular/common'; +import { inject, isDevMode, PLATFORM_ID } from '@angular/core'; + +/** + * A local copy of `cps-telemetry`'s own `cps-telemetry-safe.util.ts` — + * `cpsSafe`/`cpsSafeVoid`/`cpsIsBrowser`/`cpsIsDevMode`/`cpsUuid`, verbatim. + * + * Not imported from the main entry point: ng-packagr fixes every entry + * point's `rootDir` to its own `src` directory (unconditionally — see + * `initializeTsConfig` in ng-packagr's own `tsconfig.js`), so a secondary + * entry point cannot reach a file physically outside `rum/src` by relative + * path. The main entry deliberately doesn't export these either — per + * DESIGN.md §3, the fail-open wrappers and id generation stay internal so + * they can change freely without a breaking release, and widening the + * public API just to satisfy this entry point's own plumbing would trade + * away that freedom for every consumer, not just this one. A small, + * verbatim, unexported copy is the cheaper trade. + */ + +/** + * Runs a telemetry operation without letting it affect the application. + * + * Every public entry point in this library routes through this wrapper — + * it never rethrows. In development the caught error is reported to the + * console; in production nothing is printed. + */ +export function cpsSafe<T>(operation: string, fn: () => T, fallback: T): T { + try { + return fn(); + } catch (error) { + reportSuppressed(operation, error); + return fallback; + } +} + +/** + * Void-returning form of {@link cpsSafe}, for the common case of an emit call + * whose result nobody reads. + */ +export function cpsSafeVoid(operation: string, fn: () => void): void { + cpsSafe(operation, fn, undefined); +} + +/** `isDevMode()` throws outside an Angular context, so this guards that once for every caller. */ +export function cpsIsDevMode(): boolean { + try { + return isDevMode(); + } catch { + return false; + } +} + +/** + * Whether this code is running in a browser, as opposed to a server-side + * render. Must be called from an injection context, same as + * `inject(PLATFORM_ID)` itself requires. + */ +export function cpsIsBrowser(): boolean { + return isPlatformBrowser(inject(PLATFORM_ID)); +} + +function reportSuppressed(operation: string, error: unknown): void { + if (!cpsIsDevMode()) { + return; + } + try { + // eslint-disable-next-line no-console + console.error(`[cps-telemetry] ${operation} failed`, error); + } catch { + // A patched/throwing console must never escape telemetry suppression. + } +} + +/** + * Fills a byte array from the best available source. `crypto.getRandomValues` + * works on plain http:// too, where `crypto.randomUUID` doesn't. `Math.random` + * is the last-resort fallback for an environment with no Web Crypto at all. + */ +function randomBytes(count: number): Uint8Array { + const bytes = new Uint8Array(count); + const cryptoRef = globalThis.crypto; + + if (typeof cryptoRef?.getRandomValues === 'function') { + return cryptoRef.getRandomValues(bytes); + } + + for (let i = 0; i < count; i++) { + bytes[i] = (Math.random() * 256) | 0; + } + return bytes; +} + +/** Lowercase hex encoding, two characters per byte. */ +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Generates a correlation identifier. Prefers `crypto.randomUUID`, falling + * back to formatting random bytes as a v4 UUID. + */ +export function cpsUuid(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + + const bytes = randomBytes(16); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 + + const hex = toHex(bytes); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20) + ].join('-'); +} diff --git a/projects/cps-telemetry/rum/src/lib/cps-rum-telemetry.sink/cps-rum-telemetry.sink.spec.ts b/projects/cps-telemetry/rum/src/lib/cps-rum-telemetry.sink/cps-rum-telemetry.sink.spec.ts new file mode 100644 index 000000000..11682bb64 --- /dev/null +++ b/projects/cps-telemetry/rum/src/lib/cps-rum-telemetry.sink/cps-rum-telemetry.sink.spec.ts @@ -0,0 +1,1226 @@ +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + CPS_DEFAULT_TELEMETRY_CONFIG, + CPS_REDACT_CONFIG, + CPS_TELEMETRY_EVENT_TYPE, + CPS_TELEMETRY_IDENTITY +} from 'cps-telemetry'; +import { + CPS_RUM_CREDENTIALS_PROVIDER, + CpsRumBootstrap +} from '../cps-rum-credentials/cps-rum-credentials'; +import { CpsRumTelemetrySink } from './cps-rum-telemetry.sink'; + +/** Stand-in for the real `AwsRum` client. */ +const awsRumInstance = { + recordEvent: jest.fn(), + recordError: jest.fn(), + recordPageView: jest.fn(), + addSessionAttributes: jest.fn(), + setEventMetadataHook: jest.fn(), + setAwsCredentials: jest.fn(), + pinUserId: jest.fn(), + startSession: jest.fn(() => 'rum-session-2'), + getSessionId: jest.fn(() => 'rum-session-1'), + dispatch: jest.fn(), + dispatchBeacon: jest.fn(), + disable: jest.fn() +}; + +// Typed on the real `new AwsRum(applicationId, applicationVersion, region, +// config)` signature so `.mock.calls[0][3]` typechecks. +const AwsRumCtor = jest.fn( + ( + _applicationId: string, + _applicationVersion: string, + _region: string, + _config: unknown + ) => awsRumInstance +); + +jest.mock( + 'aws-rum-web', + () => ({ + AwsRum: function (this: unknown, ...args: unknown[]) { + return AwsRumCtor(...(args as Parameters<typeof AwsRumCtor>)); + } + }), + { virtual: true } +); + +function bootstrap( + overrides?: Partial<CpsRumBootstrap['config']>, + expiresInMs = 60 * 60 * 1000 +): CpsRumBootstrap { + return { + config: { + applicationId: 'app-monitor-1', + region: 'eu-west-1', + applicationVersion: '1.0.0', + ...overrides + }, + credentials: { + accessKeyId: 'AKIA', + secretAccessKey: 'secret', + sessionToken: 'token', + expiration: new Date(Date.now() + expiresInMs).toISOString() + } + }; +} + +describe('CpsRumTelemetrySink', () => { + let sink: CpsRumTelemetrySink; + let load: jest.Mock; + + function configure(options?: { + platformId?: object; + loadImpl?: () => Promise<CpsRumBootstrap | null>; + }): void { + load = jest.fn(options?.loadImpl ?? (async () => bootstrap())); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { + provide: CPS_TELEMETRY_IDENTITY, + useValue: { + application: 'test-app', + environment: 'prod', + version: '3.0.0' + } + }, + { + provide: CPS_REDACT_CONFIG, + useValue: CPS_DEFAULT_TELEMETRY_CONFIG.redact + }, + { provide: PLATFORM_ID, useValue: options?.platformId ?? 'browser' }, + { provide: CPS_RUM_CREDENTIALS_PROVIDER, useValue: { load } }, + CpsRumTelemetrySink + ] + }); + sink = TestBed.inject(CpsRumTelemetrySink); + } + + beforeEach(() => { + jest.clearAllMocks(); + configure(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('initialization', () => { + it('should construct the client with the broker-supplied identity', async () => { + await sink.init(); + + expect(AwsRumCtor).toHaveBeenCalledWith( + 'app-monitor-1', + '1.0.0', + 'eu-west-1', + expect.objectContaining({ + endpoint: 'https://dataplane.rum.eu-west-1.amazonaws.com' + }) + ); + }); + + it('should enable the built-in errors, performance and http telemetries', async () => { + await sink.init(); + + const config = AwsRumCtor.mock.calls[0][3] as { telemetries: unknown[] }; + expect(config.telemetries[0]).toBe('errors'); + expect(config.telemetries[1]).toBe('performance'); + expect(config.telemetries[2]).toEqual([ + 'http', + { addXRayTraceIdHeader: [expect.any(RegExp)] } + ]); + }); + + it('should scope the X-Ray trace header to the exact origin, not a look-alike host', async () => { + await sink.init(); + + const config = AwsRumCtor.mock.calls[0][3] as { + telemetries: [ + string, + string, + [string, { addXRayTraceIdHeader: RegExp[] }] + ]; + }; + const sameOrigin = config.telemetries[2][1].addXRayTraceIdHeader[0]; + const origin = window.location.origin; + + expect(sameOrigin.test(`${origin}/api/customers`)).toBe(true); + expect(sameOrigin.test(origin)).toBe(true); + expect(sameOrigin.test(`${origin}.attacker.example/api`)).toBe(false); + }); + + it('should honour explicitly supplied app monitor settings', async () => { + configure({ + loadImpl: async () => + bootstrap({ + sessionSampleRate: 0.1, + endpoint: 'https://custom.example.com', + telemetries: ['errors'], + allowCookies: false, + enableXRay: false + }) + }); + await sink.init(); + + expect(AwsRumCtor.mock.calls[0][3]).toMatchObject({ + sessionSampleRate: 0.1, + endpoint: 'https://custom.example.com', + telemetries: ['errors'], + allowCookies: false, + enableXRay: false + }); + }); + + it('should honour a representative sample of advanced app monitor settings', async () => { + configure({ + loadImpl: async () => + bootstrap({ + sessionLengthSeconds: 3600, + sessionEventLimit: 500, + suppressSessionStartEvent: true, + applicationAttributes: { tier: 'gold' }, + eventPluginsToLoad: [{ id: 'custom' }], + enableW3CTraceId: true, + headers: { 'x-test': '1' }, + batchLimit: 50, + signing: false, + compressionStrategy: { enabled: false }, + cookieAttributes: { sameSite: 'Lax' }, + disableAutoPageView: true, + pageIdFormat: 'HASH', + pagesToExclude: [/^\/admin/], + client: 'custom-client', + releaseId: 'abc123', + debug: true + }) + }); + await sink.init(); + + expect(AwsRumCtor.mock.calls[0][3]).toMatchObject({ + sessionLengthSeconds: 3600, + sessionEventLimit: 500, + suppressSessionStartEvent: true, + applicationAttributes: { tier: 'gold' }, + eventPluginsToLoad: [{ id: 'custom' }], + enableW3CTraceId: true, + headers: { 'x-test': '1' }, + batchLimit: 50, + signing: false, + compressionStrategy: { enabled: false }, + cookieAttributes: { sameSite: 'Lax' }, + disableAutoPageView: true, + pageIdFormat: 'HASH', + pagesToExclude: [/^\/admin/], + client: 'custom-client', + releaseId: 'abc123', + debug: true + }); + }); + + it('should accept a real-shaped ClientBuilder for clientBuilder with no cast', async () => { + const clientBuilder = ( + endpoint: URL, + region: string, + credentials?: + | { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + } + | (() => Promise<{ + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + }>), + compressionStrategy?: { enabled: boolean } + ) => ({ endpoint, region, credentials, compressionStrategy }); + + configure({ + loadImpl: async () => bootstrap({ clientBuilder }) + }); + await sink.init(); + + expect(AwsRumCtor.mock.calls[0][3]).toMatchObject({ clientBuilder }); + }); + + it("should infer clientBuilder's credentials/compressionStrategy parameter types from CpsRumAppMonitorConfig itself, with no explicit annotation needed", async () => { + configure({ + loadImpl: async () => + bootstrap({ + clientBuilder: ( + endpoint, + region, + credentials, + compressionStrategy + ) => ({ + endpoint, + region, + accessKeyId: + credentials && 'accessKeyId' in credentials + ? credentials.accessKeyId + : undefined, + compressionEnabled: compressionStrategy?.enabled + }) + }) + }); + await sink.init(); + + expect(AwsRumCtor.mock.calls[0][3]).toMatchObject({ + clientBuilder: expect.any(Function) + }); + }); + + it('should pass through every SDK-default-mirroring field the representative-sample test above leaves uncovered', async () => { + const fetchFunction = async () => new Response(); + + configure({ + loadImpl: async () => + bootstrap({ + userIdRetentionDays: 30, + sessionAttributes: { plan: 'enterprise' }, + recordResourceUrl: false, + dispatchInterval: 5000, + eventCacheSize: 200, + candidatesCacheSize: 100, + retries: 3, + useBeacon: false, + fetchFunction, + pagesToInclude: [/^\/dashboard/], + routeChangeComplete: 500, + routeChangeTimeout: 2000, + enableRumClient: false + }) + }); + await sink.init(); + + expect(AwsRumCtor.mock.calls[0][3]).toMatchObject({ + userIdRetentionDays: 30, + sessionAttributes: { plan: 'enterprise' }, + recordResourceUrl: false, + dispatchInterval: 5000, + eventCacheSize: 200, + candidatesCacheSize: 100, + retries: 3, + useBeacon: false, + fetchFunction, + pagesToInclude: [/^\/dashboard/], + routeChangeComplete: 500, + routeChangeTimeout: 2000, + enableRumClient: false + }); + }); + + it('should keep its own literal default only for the fields it deliberately overrides', async () => { + await sink.init(); + + expect(AwsRumCtor.mock.calls[0][3]).toMatchObject({ + allowCookies: true, + enableXRay: true, + endpoint: 'https://dataplane.rum.eu-west-1.amazonaws.com', + telemetries: [ + 'errors', + 'performance', + ['http', { addXRayTraceIdHeader: [expect.any(RegExp)] }] + ] + }); + }); + + it('should omit every SDK-default-mirroring field entirely when unset, not send it as undefined', async () => { + await sink.init(); + + const config = AwsRumCtor.mock.calls[0][3] as Record<string, unknown>; + const omittedWhenUnset = [ + 'sessionSampleRate', + 'sessionLengthSeconds', + 'sessionEventLimit', + 'suppressSessionStartEvent', + 'userIdRetentionDays', + 'sessionAttributes', + 'eventPluginsToLoad', + 'enableW3CTraceId', + 'recordResourceUrl', + 'batchLimit', + 'dispatchInterval', + 'eventCacheSize', + 'candidatesCacheSize', + 'retries', + 'useBeacon', + 'signing', + 'compressionStrategy', + 'fetchFunction', + 'pageIdFormat', + 'pagesToInclude', + 'pagesToExclude', + 'routeChangeComplete', + 'routeChangeTimeout', + 'disableAutoPageView', + 'debug', + 'enableRumClient', + 'client', + 'applicationAttributes', + 'headers', + 'alias', + 'releaseId', + 'clientBuilder', + 'cookieAttributes' + ]; + + for (const field of omittedWhenUnset) { + expect(Object.prototype.hasOwnProperty.call(config, field)).toBe(false); + } + }); + + it('should pass every SDK-default-mirroring field through untouched when the consumer sets it', async () => { + configure({ + loadImpl: async () => + bootstrap({ + sessionSampleRate: 0.5, + sessionEventLimit: 500, + pageIdFormat: 'HASH', + debug: true + }) + }); + await sink.init(); + + expect(AwsRumCtor.mock.calls[0][3]).toMatchObject({ + sessionSampleRate: 0.5, + sessionEventLimit: 500, + pageIdFormat: 'HASH', + debug: true + }); + }); + + it('should include client only when explicitly set', async () => { + configure({ + loadImpl: async () => bootstrap({ client: 'custom-client' }) + }); + await sink.init(); + + expect(AwsRumCtor.mock.calls[0][3]).toMatchObject({ + client: 'custom-client' + }); + }); + + it('should apply the credentials from the broker', async () => { + await sink.init(); + + expect(awsRumInstance.setAwsCredentials).toHaveBeenCalledWith({ + accessKeyId: 'AKIA', + secretAccessKey: 'secret', + sessionToken: 'token' + }); + }); + + it('should stamp application identity as session attributes and a metadata hook', async () => { + await sink.init(); + + const attributes = { + application: 'test-app', + environment: 'prod', + appVersion: '3.0.0' + }; + expect(awsRumInstance.addSessionAttributes).toHaveBeenCalledWith( + attributes + ); + expect(awsRumInstance.setEventMetadataHook).toHaveBeenCalled(); + + const hook = awsRumInstance.setEventMetadataHook.mock.calls[0][0]; + expect(hook()).toEqual(attributes); + }); + + it('should be a no-op during server-side rendering', async () => { + configure({ platformId: 'server' as unknown as object }); + await sink.init(); + + expect(load).not.toHaveBeenCalled(); + expect(AwsRumCtor).not.toHaveBeenCalled(); + }); + + it('should only initialize once', async () => { + await sink.init(); + await sink.init(); + + expect(AwsRumCtor).toHaveBeenCalledTimes(1); + }); + + it('should stay disabled when the broker declines', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + configure({ loadImpl: async () => null }); + await sink.init(); + + expect(AwsRumCtor).not.toHaveBeenCalled(); + + sink.record('a', {}); + sink.flush(); + expect(awsRumInstance.recordEvent).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalledWith( + expect.stringContaining('RUM event(s) lost') + ); + }); + + it('should clear the pre-init buffer, not just stop future buffering, when the broker declines', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + let resolveLoad!: (bootstrap: CpsRumBootstrap | null) => void; + configure({ + loadImpl: () => + new Promise<CpsRumBootstrap | null>((resolve) => { + resolveLoad = resolve; + }) + }); + + const initPromise = sink.init(); + sink.record('a', {}); + + resolveLoad(null); + await initPromise; + + sink.flush(); + + expect(warn).not.toHaveBeenCalledWith( + expect.stringContaining('RUM event(s) lost') + ); + }); + + it('should not throw when the broker rejects', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + configure({ + loadImpl: async () => { + throw new Error('broker unreachable'); + } + }); + + await expect(sink.init()).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledWith( + '[cps-telemetry] RUM init failed, monitoring disabled', + expect.objectContaining({ + name: 'Error', + message: 'broker unreachable' + }) + ); + }); + + it('should disable the client, not just log a warning, when the SDK throws after construction', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + awsRumInstance.addSessionAttributes.mockImplementationOnce(() => { + throw new Error('sdk exploded'); + }); + + await sink.init(); + + expect(AwsRumCtor).toHaveBeenCalled(); + expect(awsRumInstance.disable).toHaveBeenCalled(); + + sink.record('a', {}); + expect(awsRumInstance.recordEvent).not.toHaveBeenCalled(); + }); + + it('should disable RUM and clear the pre-init buffer, not buffer forever, when init throws', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + let rejectLoad!: (error: Error) => void; + configure({ + loadImpl: () => + new Promise<CpsRumBootstrap | null>((_resolve, reject) => { + rejectLoad = reject; + }) + }); + + const initPromise = sink.init(); + sink.record('a', {}); + + rejectLoad(new Error('broker unreachable')); + await initPromise; + + sink.record('b', {}); + sink.flush(); + + expect(awsRumInstance.recordEvent).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalledWith( + expect.stringContaining('RUM event(s) lost') + ); + }); + + it("should not reject init()'s promise even if console.warn itself throws", async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => { + throw new Error('console patched to throw'); + }); + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + configure({ + loadImpl: async () => { + throw new Error('broker unreachable'); + } + }); + + await expect(sink.init()).resolves.toBeUndefined(); + + warn.mockRestore(); + error.mockRestore(); + }); + + it('should leave the application functional after a failed init', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + configure({ + loadImpl: async () => { + throw new Error('broker unreachable'); + } + }); + await sink.init(); + + expect(() => sink.record('t', {})).not.toThrow(); + expect(() => sink.flush()).not.toThrow(); + expect(sink.getSessionId()).toBeUndefined(); + }); + + it('should not construct the RUM client if destroyed while the first load is in flight', async () => { + let resolveLoad!: (bootstrap: CpsRumBootstrap | null) => void; + configure({ + loadImpl: () => + new Promise<CpsRumBootstrap | null>((resolve) => { + resolveLoad = resolve; + }) + }); + + const initPromise = sink.init(); + sink.ngOnDestroy(); + resolveLoad(bootstrap()); + await initPromise; + + expect(AwsRumCtor).not.toHaveBeenCalled(); + }); + + it('should disable the already-constructed AWS RUM client on destroy, not just stop the credential-refresh timer', async () => { + await sink.init(); + expect(AwsRumCtor).toHaveBeenCalled(); + + sink.ngOnDestroy(); + + expect(awsRumInstance.disable).toHaveBeenCalled(); + }); + + it('should work without credentials for an unauthenticated app monitor', async () => { + configure({ + loadImpl: async () => ({ config: bootstrap().config }) + }); + await sink.init(); + + expect(AwsRumCtor).toHaveBeenCalled(); + expect(awsRumInstance.setAwsCredentials).not.toHaveBeenCalled(); + }); + }); + + describe('recording', () => { + it('should forward events to the client once initialized', async () => { + await sink.init(); + sink.record(CPS_TELEMETRY_EVENT_TYPE.bi, { eventName: 'clicked' }); + + expect(awsRumInstance.recordEvent).toHaveBeenCalledWith( + CPS_TELEMETRY_EVENT_TYPE.bi, + { eventName: 'clicked' }, + undefined + ); + }); + + it('should buffer events recorded before init and replay them in order', async () => { + sink.record('a', { n: 1 }); + sink.record('b', { n: 2 }); + + expect(awsRumInstance.recordEvent).not.toHaveBeenCalled(); + + await sink.init(); + + expect(awsRumInstance.recordEvent).toHaveBeenCalledTimes(2); + expect(awsRumInstance.recordEvent.mock.calls[0][0]).toBe('a'); + expect(awsRumInstance.recordEvent.mock.calls[1][0]).toBe('b'); + }); + + it('should self-initialize and eventually deliver even if init() is never called directly', async () => { + sink.record('a', { n: 1 }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(awsRumInstance.recordEvent).toHaveBeenCalledWith( + 'a', + { n: 1 }, + undefined + ); + }); + + it('should drop the oldest buffered events beyond the limit', async () => { + for (let i = 0; i < 150; i++) { + sink.record('e', { i }); + } + await sink.init(); + + expect(awsRumInstance.recordEvent).toHaveBeenCalledTimes(100); + expect(awsRumInstance.recordEvent.mock.calls[0][1]).toEqual({ i: 50 }); + }); + + it('should not buffer during server-side rendering', async () => { + configure({ platformId: 'server' as unknown as object }); + sink.record('a', {}); + await sink.init(); + + expect(awsRumInstance.recordEvent).not.toHaveBeenCalled(); + }); + + it('should drop metadata keys in the reserved aws: namespace', async () => { + await sink.init(); + sink.record('a', {}, { 'aws:client': 'x', keep: 'yes', dropped: null }); + + expect(awsRumInstance.recordEvent).toHaveBeenCalledWith( + 'a', + {}, + { + keep: 'yes' + } + ); + }); + + it('should pass no metadata when nothing survives sanitization', async () => { + await sink.init(); + sink.record('a', {}, { 'aws:client': 'x' }); + + expect(awsRumInstance.recordEvent).toHaveBeenCalledWith( + 'a', + {}, + undefined + ); + }); + + it('should forward handled errors to the client', async () => { + await sink.init(); + sink.recordError({ name: 'TypeError', message: 'boom' }); + + expect(awsRumInstance.recordError).toHaveBeenCalledWith({ + name: 'TypeError', + message: 'boom' + }); + }); + + it('should fold a forwarded error origin into its name, since the SDK has no metadata channel for errors', async () => { + await sink.init(); + sink.recordError( + { name: 'TypeError', message: 'boom' }, + { application: 'fragment-app' } + ); + + expect(awsRumInstance.recordError).toHaveBeenCalledWith({ + name: '[fragment-app] TypeError', + message: 'boom' + }); + }); + + it('should not modify the error when its origin matches this realm', async () => { + await sink.init(); + sink.recordError( + { name: 'TypeError', message: 'boom' }, + { application: 'test-app' } + ); + + expect(awsRumInstance.recordError).toHaveBeenCalledWith({ + name: 'TypeError', + message: 'boom' + }); + }); + + it('should buffer an error recorded before init and replay it once ready', async () => { + sink.recordError({ name: 'TypeError', message: 'boom' }); + + expect(awsRumInstance.recordError).not.toHaveBeenCalled(); + + await sink.init(); + + expect(awsRumInstance.recordError).toHaveBeenCalledWith({ + name: 'TypeError', + message: 'boom' + }); + }); + + it('should fold origin into a buffered error at replay time, same as an immediate one', async () => { + sink.recordError( + { name: 'TypeError', message: 'boom' }, + { application: 'fragment-app' } + ); + + await sink.init(); + + expect(awsRumInstance.recordError).toHaveBeenCalledWith({ + name: '[fragment-app] TypeError', + message: 'boom' + }); + }); + + it('should forward page views', async () => { + await sink.init(); + sink.recordPageView('/customers'); + + expect(awsRumInstance.recordPageView).toHaveBeenCalledWith('/customers'); + }); + + it('should buffer a page view recorded before init and replay it, same as record()', async () => { + sink.recordPageView('/customers'); + + expect(awsRumInstance.recordPageView).not.toHaveBeenCalled(); + + await sink.init(); + + expect(awsRumInstance.recordPageView).toHaveBeenCalledWith('/customers'); + }); + + it('should replay buffered events and page views in the order they were recorded', async () => { + sink.record('a', { n: 1 }); + sink.recordPageView('/customers'); + sink.record('b', { n: 2 }); + + await sink.init(); + + expect(awsRumInstance.recordEvent.mock.calls[0][0]).toBe('a'); + expect(awsRumInstance.recordPageView).toHaveBeenCalledWith('/customers'); + expect(awsRumInstance.recordEvent.mock.calls[1][0]).toBe('b'); + }); + + it('should not throw when the client throws', async () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await sink.init(); + awsRumInstance.recordEvent.mockImplementationOnce(() => { + throw new Error('sdk exploded'); + }); + + expect(() => sink.record('a', {})).not.toThrow(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('rum.record failed'), + expect.any(Error) + ); + + consoleError.mockRestore(); + }); + }); + + describe('identity', () => { + it('should pin a user id set after init', async () => { + await sink.init(); + sink.setUserId('user-42'); + + expect(awsRumInstance.pinUserId).toHaveBeenCalledWith('user-42'); + }); + + it('should start a fresh anonymous session on sign-out', async () => { + await sink.init(); + sink.setUserId('user-42'); + + sink.setUserId(undefined); + + expect(awsRumInstance.startSession).toHaveBeenCalledWith({ + userId: expect.stringMatching( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + ) + }); + }); + + it('should not re-pin a signed-out user when the client starts later', async () => { + sink.setUserId('user-42'); + sink.setUserId(undefined); + + await sink.init(); + + expect(awsRumInstance.pinUserId).not.toHaveBeenCalled(); + }); + + it("should treat '' as sign-out, not as a user id", async () => { + await sink.init(); + sink.setUserId('user-42'); + awsRumInstance.pinUserId.mockClear(); + + sink.setUserId(''); + + expect(awsRumInstance.pinUserId).not.toHaveBeenCalled(); + expect(awsRumInstance.startSession).toHaveBeenCalledWith({ + userId: expect.any(String) + }); + }); + + it("should not pin '' when the client starts later", async () => { + sink.setUserId(''); + + await sink.init(); + + expect(awsRumInstance.pinUserId).not.toHaveBeenCalled(); + }); + + it('should pin a user id set before init once the client exists', async () => { + sink.setUserId('user-42'); + expect(awsRumInstance.pinUserId).not.toHaveBeenCalled(); + + await sink.init(); + + expect(awsRumInstance.pinUserId).toHaveBeenCalledWith('user-42'); + }); + + it('should report the client session id', async () => { + await sink.init(); + expect(sink.getSessionId()).toBe('rum-session-1'); + }); + + it('should report no session id before init', () => { + expect(sink.getSessionId()).toBeUndefined(); + }); + }); + + describe('flushing', () => { + it('should dispatch normally by default', async () => { + await sink.init(); + sink.flush(); + + expect(awsRumInstance.dispatch).toHaveBeenCalled(); + expect(awsRumInstance.dispatchBeacon).not.toHaveBeenCalled(); + }); + + it('should use a beacon when asked, so unload does not lose the batch', async () => { + await sink.init(); + sink.flush(true); + + expect(awsRumInstance.dispatchBeacon).toHaveBeenCalled(); + expect(awsRumInstance.dispatch).not.toHaveBeenCalled(); + }); + }); + + describe('credential refresh', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('should refresh shortly before the credentials expire', async () => { + configure({ loadImpl: async () => bootstrap(undefined, 60 * 60 * 1000) }); + await sink.init(); + + expect(load).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(55 * 60 * 1000); + + expect(load).toHaveBeenCalledTimes(2); + expect(awsRumInstance.setAwsCredentials).toHaveBeenCalledTimes(2); + }); + + it('should retry after the bounded delay for already-expired credentials, instead of refreshing immediately', async () => { + configure({ loadImpl: async () => bootstrap(undefined, -1000) }); + await sink.init(); + + expect(load).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(0); + expect(load).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(30 * 1000); + expect(load).toHaveBeenCalledTimes(2); + }); + + it('should retry, not schedule a near-immediate refresh, for credentials expiring within the skew window', async () => { + configure({ loadImpl: async () => bootstrap(undefined, 2 * 60 * 1000) }); + await sink.init(); + + expect(load).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(0); + expect(load).toHaveBeenCalledTimes(1); + + await jest.advanceTimersByTimeAsync(30 * 1000); + expect(load).toHaveBeenCalledTimes(2); + }); + + it('should cap delay at 2^31-1 ms to prevent 32-bit integer overflow for far-future credentials', async () => { + configure({ + loadImpl: async () => bootstrap(undefined, 100 * 24 * 60 * 60 * 1000) + }); + await sink.init(); + + await jest.advanceTimersByTimeAsync(1000); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('should not throw when a refresh fails', async () => { + let calls = 0; + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + configure({ + loadImpl: async () => { + if (calls++ === 0) { + return bootstrap(undefined, 6 * 60 * 1000); + } + throw new Error('broker down'); + } + }); + await sink.init(); + + await jest.advanceTimersByTimeAsync(60 * 1000); + + expect(warn).toHaveBeenCalledWith( + '[cps-telemetry] RUM credential refresh failed', + expect.objectContaining({ name: 'Error', message: 'broker down' }) + ); + }); + + it('should cancel the pending refresh on destroy', async () => { + configure({ loadImpl: async () => bootstrap(undefined, 60 * 60 * 1000) }); + await sink.init(); + + sink.ngOnDestroy(); + await jest.advanceTimersByTimeAsync(60 * 60 * 1000); + + expect(load).toHaveBeenCalledTimes(1); + }); + + it('should retry a failed refresh instead of the chain dying permanently', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + let calls = 0; + configure({ + loadImpl: async () => { + calls++; + if (calls === 2) { + throw new Error('transient broker blip'); + } + return bootstrap(undefined, 6 * 60 * 1000); + } + }); + + return sink.init().then(async () => { + expect(calls).toBe(1); + + await jest.advanceTimersByTimeAsync(60 * 1000); + expect(calls).toBe(2); + expect(awsRumInstance.setAwsCredentials).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + '[cps-telemetry] RUM credential refresh failed', + expect.objectContaining({ + name: 'Error', + message: 'transient broker blip' + }) + ); + + await jest.advanceTimersByTimeAsync(30 * 1000); + expect(calls).toBe(3); + expect(awsRumInstance.setAwsCredentials).toHaveBeenCalledTimes(2); + + warn.mockRestore(); + }); + }); + + it('should not retry when the very first load is unauthenticated (credentials omitted from the start)', async () => { + configure({ + loadImpl: async () => ({ config: bootstrap().config }) + }); + await sink.init(); + + expect(awsRumInstance.setAwsCredentials).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(60 * 60 * 1000); + expect(load).toHaveBeenCalledTimes(1); + }); + + it('should keep retrying, not go silent forever, when an already-authenticated session refreshes into credentials omitted', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + let calls = 0; + configure({ + loadImpl: async () => { + calls++; + if (calls === 2) { + return { config: bootstrap().config }; + } + return bootstrap(undefined, 6 * 60 * 1000); + } + }); + await sink.init(); + + await jest.advanceTimersByTimeAsync(60 * 1000); + expect(calls).toBe(2); + expect(awsRumInstance.setAwsCredentials).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + '[cps-telemetry] RUM credential refresh', + expect.objectContaining({ + name: 'Error', + message: expect.stringContaining('already-authenticated') + }) + ); + + await jest.advanceTimersByTimeAsync(30 * 1000); + expect(calls).toBe(3); + + warn.mockRestore(); + }); + + it('should disable RUM for the session when a scheduled refresh returns null, per the provider contract', async () => { + let calls = 0; + configure({ + loadImpl: async () => { + calls++; + if (calls === 2) { + return null; + } + return bootstrap(undefined, 6 * 60 * 1000); + } + }); + await sink.init(); + + await jest.advanceTimersByTimeAsync(60 * 1000); + expect(calls).toBe(2); + + // No further refresh or retry should be scheduled once disabled. + await jest.advanceTimersByTimeAsync(60 * 60 * 1000); + expect(calls).toBe(2); + + sink.record('a', {}); + expect(awsRumInstance.recordEvent).not.toHaveBeenCalled(); + }); + + it('should disable the already-constructed AWS RUM client, not just drop the reference, when a scheduled refresh returns null', async () => { + let calls = 0; + configure({ + loadImpl: async () => { + calls++; + if (calls === 2) { + return null; + } + return bootstrap(undefined, 6 * 60 * 1000); + } + }); + await sink.init(); + expect(awsRumInstance.disable).not.toHaveBeenCalled(); + + await jest.advanceTimersByTimeAsync(60 * 1000); + + expect(awsRumInstance.disable).toHaveBeenCalled(); + }); + + it('should drop, not buffer, events recorded after being disabled', async () => { + let calls = 0; + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + configure({ + loadImpl: async () => { + calls++; + if (calls === 2) { + return null; + } + return bootstrap(undefined, 6 * 60 * 1000); + } + }); + await sink.init(); + + await jest.advanceTimersByTimeAsync(60 * 1000); + expect(calls).toBe(2); + + sink.record('a', {}); + sink.record('b', {}); + sink.flush(); + + expect(warn).not.toHaveBeenCalledWith( + expect.stringContaining('RUM event(s) lost') + ); + }); + + it('should not arm a new refresh timer if destroyed while a refresh is in flight', async () => { + let resolveSecondLoad!: (bootstrap: CpsRumBootstrap) => void; + let calls = 0; + configure({ + loadImpl: () => { + calls++; + if (calls === 1) { + return Promise.resolve(bootstrap(undefined, 6 * 60 * 1000)); + } + return new Promise<CpsRumBootstrap>((resolve) => { + resolveSecondLoad = resolve; + }); + } + }); + await sink.init(); + + await jest.advanceTimersByTimeAsync(60 * 1000); + expect(calls).toBe(2); + + sink.ngOnDestroy(); + + resolveSecondLoad(bootstrap(undefined, 6 * 60 * 1000)); + await Promise.resolve(); + await Promise.resolve(); + + await jest.advanceTimersByTimeAsync(10 * 60 * 1000); + expect(calls).toBe(2); + }); + + it('should not report a failure for a refresh that rejects after the sink was destroyed', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + let rejectSecondLoad!: (error: Error) => void; + let calls = 0; + configure({ + loadImpl: () => { + calls++; + if (calls === 1) { + return Promise.resolve(bootstrap(undefined, 6 * 60 * 1000)); + } + return new Promise<CpsRumBootstrap>((_resolve, reject) => { + rejectSecondLoad = reject; + }); + } + }); + await sink.init(); + + await jest.advanceTimersByTimeAsync(60 * 1000); + expect(calls).toBe(2); + + sink.ngOnDestroy(); + + rejectSecondLoad(new Error('broker down')); + await Promise.resolve(); + await Promise.resolve(); + + expect(warn).not.toHaveBeenCalled(); + }); + }); + + describe('flush before init resolves', () => { + it('should warn in development and not throw when buffered events cannot be flushed', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + let resolveLoad!: (bootstrap: CpsRumBootstrap | null) => void; + configure({ + loadImpl: () => + new Promise<CpsRumBootstrap | null>((resolve) => { + resolveLoad = resolve; + }) + }); + + const initPromise = sink.init(); + sink.record('com.cps.bi', { eventName: 'x' }); + + expect(() => sink.flush(true)).not.toThrow(); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('1 RUM event(s) lost') + ); + + resolveLoad(bootstrap()); + await initPromise; + }); + + it('should not warn when the buffer is empty', async () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + let resolveLoad!: (bootstrap: CpsRumBootstrap | null) => void; + configure({ + loadImpl: () => + new Promise<CpsRumBootstrap | null>((resolve) => { + resolveLoad = resolve; + }) + }); + + const initPromise = sink.init(); + sink.flush(true); + + expect(warn).not.toHaveBeenCalled(); + + resolveLoad(bootstrap()); + await initPromise; + }); + }); +}); diff --git a/projects/cps-telemetry/rum/src/lib/cps-rum-telemetry.sink/cps-rum-telemetry.sink.ts b/projects/cps-telemetry/rum/src/lib/cps-rum-telemetry.sink/cps-rum-telemetry.sink.ts new file mode 100644 index 000000000..e256f0e81 --- /dev/null +++ b/projects/cps-telemetry/rum/src/lib/cps-rum-telemetry.sink/cps-rum-telemetry.sink.ts @@ -0,0 +1,616 @@ +import { DOCUMENT } from '@angular/common'; +import { inject, Injectable, OnDestroy } from '@angular/core'; +import type { AwsRum, AwsRumConfig } from 'aws-rum-web'; +import { + CPS_REDACT_CONFIG, + CPS_TELEMETRY_IDENTITY, + CpsTelemetryError, + CpsTelemetryMetadata, + CpsTelemetrySink, + cpsNormalizeError +} from 'cps-telemetry'; +import { + cpsIsBrowser, + cpsIsDevMode, + cpsSafe, + cpsSafeVoid, + cpsUuid +} from '../cps-rum-internal.util/cps-rum-internal.util'; +import { + CPS_RUM_CREDENTIALS_PROVIDER, + CpsRumBootstrap, + CpsRumCredentials +} from '../cps-rum-credentials/cps-rum-credentials'; + +/** Refresh credentials this long before they expire. */ +const CREDENTIAL_REFRESH_SKEW_MS = 5 * 60 * 1000; + +/** + * Maximum delay supported by setTimeout (2^31 - 1 ms, ~24.85 days). + * Prevents 32-bit signed integer overflow triggering an immediate execution. + */ +const MAX_TIMEOUT_MS = 2_147_483_647; + +/** + * Retry delay after a scheduled refresh fails or returns no credentials. + * Shorter than the normal refresh-ahead-of-expiry interval. + */ +const CREDENTIAL_RETRY_DELAY_MS = 30 * 1000; + +/** + * Events retained while the SDK loads. Bounded so a failed init cannot + * grow the buffer without limit. + */ +const PRE_INIT_BUFFER_LIMIT = 100; + +/** Metadata keys under this prefix are reserved by the RUM client. */ +const RESERVED_PREFIX = 'aws:'; + +interface BufferedEvent { + kind: 'event'; + eventType: string; + payload: object; + metadata?: CpsTelemetryMetadata; +} + +interface BufferedPageView { + kind: 'pageView'; + pageId: string; +} + +interface BufferedError { + kind: 'error'; + error: CpsTelemetryError; + metadata?: CpsTelemetryMetadata; +} + +/** Anything the pre-init buffer holds. */ +type BufferedItem = BufferedEvent | BufferedPageView | BufferedError; + +/** + * Drops every key whose value is `undefined`. + * + * Used to build the SDK config object from optional consumer fields: a key + * present with value `undefined` would overwrite the SDK's own default for + * that key, so unset fields must be absent rather than `undefined`. + */ +function omitUndefined<T extends object>(obj: T): Partial<T> { + const result: Partial<T> = {}; + for (const key of Object.keys(obj) as (keyof T)[]) { + if (obj[key] !== undefined) { + result[key] = obj[key]; + } + } + return result; +} + +/** + * Telemetry sink backed by the AWS CloudWatch RUM web client. + * + * Custom scenario and BI events travel through `recordEvent`, alongside the + * page views, web vitals, JS errors, HTTP failures and resource timing the + * client records on its own. The SDK is imported lazily, so an application + * that never provides this sink never pays for the bundle. Every operation + * is fail-open: if the broker is down, credentials lapse, or the SDK + * throws, the application behaves as if RUM were healthy. + * + * @group Services + */ +@Injectable() +export class CpsRumTelemetrySink extends CpsTelemetrySink implements OnDestroy { + private readonly config = inject(CPS_TELEMETRY_IDENTITY); + private readonly redact = inject(CPS_REDACT_CONFIG); + private readonly credentialsProvider = inject(CPS_RUM_CREDENTIALS_PROVIDER); + private readonly document = inject(DOCUMENT); + private readonly isBrowser = cpsIsBrowser(); + + private awsRum: AwsRum | null = null; + private refreshTimer: ReturnType<typeof setTimeout> | null = null; + private buffer: BufferedItem[] = []; + private userId?: string; + /** Set once a refresh returns null/undefined; distinct from `!awsRum` alone. */ + private disabled = false; + /** + * Memoized so every caller — `provideCpsTelemetryRumSink()`'s + * `APP_INITIALIZER`, {@link ensureInitialized}, and a caller awaiting + * `init()` directly — awaits the same underlying work, regardless of who + * triggered it first. + */ + private initPromise?: Promise<void>; + /** + * Set once in `ngOnDestroy`. `refreshCredentials()` nulls `refreshTimer` + * before its own `await`, so a destroy during that window would leave a + * new timer nothing could clear. Checked after every await in the + * refresh chain instead. + */ + private destroyed = false; + + /** + * Loads and starts the RUM client. + * + * Fire-and-forget: it must never block bootstrap and never reject into the + * caller. Safe to call more than once; subsequent calls are no-ops. + * + * @returns a promise that resolves once initialization has been attempted + */ + async init(): Promise<void> { + if (!this.isBrowser) { + return; + } + if (!this.initPromise) { + this.initPromise = this.performInit(); + } + return this.initPromise; + } + + private async performInit(): Promise<void> { + try { + const bootstrap = await this.credentialsProvider.load(); + if (!bootstrap?.config) { + this.disableAwsRum(); + this.buffer = []; + return; + } + + const { AwsRum } = await import('aws-rum-web'); + if (this.destroyed) { + return; + } + + this.awsRum = new AwsRum( + bootstrap.config.applicationId, + bootstrap.config.applicationVersion, + bootstrap.config.region, + this.buildRumConfig(bootstrap) + ); + + this.applyIdentity(); + this.applyCredentials(bootstrap.credentials); + this.replayBuffer(); + } catch (error) { + this.disableAwsRum(); + this.buffer = []; + this.reportFailure('RUM init failed, monitoring disabled', error); + } + } + + /** + * Kicks off `init()` on first real use, for a sink bound directly (e.g. + * `{ provide: CpsTelemetrySink, useClass: CpsRumTelemetrySink }`) rather + * than through `provideCpsTelemetryRumSink()`, which is what normally + * calls `init()` via `APP_INITIALIZER`. `init()` is idempotent and + * memoized, so this is a no-op once already initialized or already in + * flight, and never races with a caller awaiting `init()` directly. + */ + private ensureInitialized(): void { + if (!this.initPromise) { + this.init().catch(() => undefined); + } + } + + /** + * Shared shape behind {@link record}, {@link recordError} and + * {@link recordPageView}: kicks off init, then either buffers `item` + * (pre-init) or hands the live client to `dispatch`, all inside the same + * fail-open guard. + */ + private recordOrBuffer( + operation: string, + item: BufferedItem, + dispatch: (awsRum: AwsRum) => void + ): void { + this.ensureInitialized(); + cpsSafeVoid(operation, () => { + if (!this.isBrowser || this.disabled) { + return; + } + + const awsRum = this.awsRum; + if (!awsRum) { + this.bufferItem(item); + return; + } + + dispatch(awsRum); + }); + } + + /** @inheritdoc */ + record( + eventType: string, + payload: object, + metadata?: CpsTelemetryMetadata + ): void { + this.recordOrBuffer( + 'rum.record', + { kind: 'event', eventType, payload, metadata }, + (awsRum) => + awsRum.recordEvent(eventType, payload, this.sanitize(metadata)) + ); + } + + /** @inheritdoc */ + recordError(error: CpsTelemetryError, metadata?: CpsTelemetryMetadata): void { + this.recordOrBuffer( + 'rum.recordError', + { kind: 'error', error, metadata }, + (awsRum) => awsRum.recordError(this.withOrigin(error, metadata)) + ); + } + + /** @inheritdoc */ + getSessionId(): string | undefined { + return cpsSafe( + 'rum.getSessionId', + () => this.awsRum?.getSessionId(), + undefined + ); + } + + /** + * Associates subsequent events with a user, or with nobody. + * + * Signing in pins the id, and works even before `init()` resolves. + * Signing out starts a fresh session with a new anonymous id (`pinUserId` + * has no inverse), which emits a `session_start` event and re-rolls + * sampling. + * + * @param userId the application's own user identifier, or `undefined` + * (or `''`) on sign-out + */ + setUserId(userId: string | undefined): void { + cpsSafeVoid('rum.setUserId', () => { + this.userId = userId; + + if (userId) { + this.awsRum?.pinUserId(userId); + return; + } + + this.awsRum?.startSession({ userId: cpsUuid() }); + }); + } + + /** @inheritdoc */ + getUserId(): string | undefined { + return this.userId; + } + + /** @inheritdoc */ + flush(beacon = false): void { + cpsSafeVoid('rum.flush', () => { + if (!this.awsRum) { + if (this.buffer.length > 0 && cpsIsDevMode()) { + // eslint-disable-next-line no-console + console.warn( + `[cps-telemetry] ${this.buffer.length} RUM event(s) lost: page unloaded before RUM finished initializing` + ); + } + return; + } + + if (beacon) { + this.awsRum.dispatchBeacon(); + } else { + this.awsRum.dispatch(); + } + }); + } + + /** + * Records a page view. + * + * The RUM client already records a page view on every navigation via + * `history.pushState`, covering Angular's router automatically. Use this + * to report a different page id (e.g. a route template instead of the + * resolved URL); also set `disableAutoPageView: true` in the RUM config + * to avoid recording the navigation twice. Buffered like {@link record} + * when called before `init()` resolves. + * + * @param pageId the page identity, e.g. `/customers/:id` + */ + recordPageView(pageId: string): void { + this.recordOrBuffer( + 'rum.recordPageView', + { kind: 'pageView', pageId }, + (awsRum) => awsRum.recordPageView(pageId) + ); + } + + /** @inheritdoc */ + ngOnDestroy(): void { + this.destroyed = true; + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + this.disableAwsRum(); + } + + /** + * Disables the sink: stops the AWS RUM client's own listeners, plugins, + * and dispatch timer before dropping the reference — clearing `awsRum` + * alone only stops this class from talking to it; the already-constructed + * instance would otherwise keep running orphaned in the page (and, on + * destroy, could duplicate telemetry if the app is bootstrapped again in + * the same one) — and marks the session disabled, so every call site that + * tears the client down does so atomically with the flag `recordOrBuffer` + * checks, instead of each caller having to remember to set both. + */ + private disableAwsRum(): void { + cpsSafeVoid('rum.disable', () => this.awsRum?.disable()); + this.awsRum = null; + this.disabled = true; + } + + /** + * Maps {@link CpsRumAppMonitorConfig} onto the SDK's own `PartialConfig`. + * + * The SDK merges this object over its own defaults with a shallow + * `Object.assign`, so a key present with value `undefined` overwrites the + * SDK's default instead of falling through to it. `allowCookies`, + * `enableXRay`, `endpoint` and `telemetries` always carry this library's + * own override; every other field goes through {@link omitUndefined} (or, + * for `client`, a conditional spread) so an unset field is absent and the + * SDK's own default applies. + */ + private buildRumConfig(bootstrap: CpsRumBootstrap): AwsRumConfig { + const { config } = bootstrap; + const origin = this.document.defaultView?.location?.origin ?? ''; + + // X-Ray trace header only on same-origin requests; a cross-origin + // request with an unexpected header fails CORS preflight. Anchored at + // both ends (with a path/query/fragment/end-of-string lookahead) so + // `https://example.com` doesn't also match a look-alike host like + // `https://example.com.attacker.example`. + const sameOrigin = new RegExp( + '^' + origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:[/?#]|$)' + ); + + return { + allowCookies: config.allowCookies ?? true, + enableXRay: config.enableXRay ?? true, + endpoint: + config.endpoint ?? + `https://dataplane.rum.${config.region}.amazonaws.com`, + telemetries: config.telemetries ?? [ + 'errors', + 'performance', + ['http', { addXRayTraceIdHeader: [sameOrigin] }] + ], + + ...(config.client !== undefined ? { client: config.client } : {}), + + ...omitUndefined({ + releaseId: config.releaseId, + clientBuilder: config.clientBuilder, + applicationAttributes: config.applicationAttributes, + alias: config.alias, + headers: config.headers, + cookieAttributes: config.cookieAttributes, + sessionSampleRate: config.sessionSampleRate, + sessionLengthSeconds: config.sessionLengthSeconds, + sessionEventLimit: config.sessionEventLimit, + suppressSessionStartEvent: config.suppressSessionStartEvent, + userIdRetentionDays: config.userIdRetentionDays, + sessionAttributes: config.sessionAttributes, + eventPluginsToLoad: config.eventPluginsToLoad, + enableW3CTraceId: config.enableW3CTraceId, + recordResourceUrl: config.recordResourceUrl, + batchLimit: config.batchLimit, + dispatchInterval: config.dispatchInterval, + eventCacheSize: config.eventCacheSize, + candidatesCacheSize: config.candidatesCacheSize, + retries: config.retries, + useBeacon: config.useBeacon, + signing: config.signing, + compressionStrategy: config.compressionStrategy, + fetchFunction: config.fetchFunction, + pageIdFormat: config.pageIdFormat, + pagesToInclude: config.pagesToInclude, + pagesToExclude: config.pagesToExclude, + routeChangeComplete: config.routeChangeComplete, + routeChangeTimeout: config.routeChangeTimeout, + disableAutoPageView: config.disableAutoPageView, + debug: config.debug, + enableRumClient: config.enableRumClient + }) + } as unknown as AwsRumConfig; + } + + /** + * Attaches application identity once per session rather than once per event. + * + * `addSessionAttributes` covers custom events; the metadata hook stamps + * the same attributes onto the client's own built-in events too. + * + * See {@link CpsRumAppMonitorConfig.sessionAttributes} for how this + * merges with a consumer-supplied base. + */ + private applyIdentity(): void { + if (!this.awsRum) { + return; + } + + const attributes = { + application: this.config.application, + environment: this.config.environment, + appVersion: this.config.version + }; + + this.awsRum.addSessionAttributes(attributes); + this.awsRum.setEventMetadataHook(() => attributes); + + if (this.userId) { + this.awsRum.pinUserId(this.userId); + } + } + + private applyCredentials(credentials?: CpsRumCredentials): void { + if (!this.awsRum || !credentials) { + return; + } + + this.awsRum.setAwsCredentials({ + accessKeyId: credentials.accessKeyId, + secretAccessKey: credentials.secretAccessKey, + sessionToken: credentials.sessionToken + }); + + this.scheduleRefresh(credentials.expiration); + } + + private scheduleRefresh(expiration: string): void { + const delay = + new Date(expiration).getTime() - Date.now() - CREDENTIAL_REFRESH_SKEW_MS; + + if (!Number.isFinite(delay) || delay <= 0) { + // Avoid tight-looping on an already-expired/unparsable expiration. + this.scheduleRetry(); + return; + } + + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + this.refreshTimer = setTimeout( + () => { + this.refreshCredentials().catch(() => undefined); + }, + Math.min(delay, MAX_TIMEOUT_MS) + ); + } + + private async refreshCredentials(): Promise<void> { + this.refreshTimer = null; + try { + const bootstrap = await this.credentialsProvider.load(); + if (this.destroyed) { + return; + } + + if (!bootstrap) { + // null/undefined disables RUM for the session (see load()'s contract). + this.disableAwsRum(); + return; + } + + if (bootstrap.credentials) { + this.applyCredentials(bootstrap.credentials); + } else { + this.scheduleRetry(); + this.reportFailure( + 'RUM credential refresh', + new Error( + 'bootstrap omitted credentials for an already-authenticated ' + + 'session; keeping existing credentials and retrying' + ) + ); + } + } catch (error) { + if (!this.destroyed) { + this.scheduleRetry(); + this.reportFailure('RUM credential refresh failed', error); + } + } + } + + private scheduleRetry(): void { + if (this.destroyed) { + return; + } + + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + + this.refreshTimer = setTimeout(() => { + this.refreshCredentials().catch(() => undefined); + }, CREDENTIAL_RETRY_DELAY_MS); + } + + /** + * Reports an init/refresh failure without leaking raw error content into + * the production console. + */ + private reportFailure(operation: string, error: unknown): void { + if (!cpsIsDevMode()) { + return; + } + cpsSafeVoid('rum.reportFailure', () => { + // eslint-disable-next-line no-console + console.warn( + `[cps-telemetry] ${operation}`, + cpsNormalizeError(error, this.redact) + ); + }); + } + + private bufferItem(item: BufferedItem): void { + this.buffer.push(item); + if (this.buffer.length > PRE_INIT_BUFFER_LIMIT) { + this.buffer.shift(); + } + } + + private replayBuffer(): void { + const pending = this.buffer; + this.buffer = []; + + for (const item of pending) { + cpsSafeVoid('rum.replay', () => { + if (item.kind === 'event') { + this.awsRum?.recordEvent( + item.eventType, + item.payload, + this.sanitize(item.metadata) + ); + } else if (item.kind === 'pageView') { + this.awsRum?.recordPageView(item.pageId); + } else { + this.awsRum?.recordError(this.withOrigin(item.error, item.metadata)); + } + }); + } + } + + /** + * Drops metadata keys under the reserved `aws:` prefix, which the client + * would otherwise discard with a console warning. Also drops `null` + * values, which {@link CpsTelemetryMetadata} permits but the SDK's + * metadata type does not. + */ + private sanitize( + metadata?: CpsTelemetryMetadata + ): Record<string, string | number | boolean> | undefined { + if (!metadata) { + return undefined; + } + + const result: Record<string, string | number | boolean> = {}; + for (const [key, value] of Object.entries(metadata)) { + if (key.startsWith(RESERVED_PREFIX) || value === null) { + continue; + } + result[key] = value; + } + + return Object.keys(result).length ? result : undefined; + } + + /** + * Folds a forwarded error's origin into its name. `AwsRum.recordError` + * takes no metadata argument, so `name` is the only field left to carry it. + */ + private withOrigin( + error: CpsTelemetryError, + metadata?: CpsTelemetryMetadata + ): CpsTelemetryError { + const origin = metadata?.application; + if (typeof origin !== 'string' || origin === this.config.application) { + return error; + } + return { ...error, name: `[${origin}] ${error.name}` }; + } +} diff --git a/projects/cps-telemetry/rum/src/lib/cps-rum.providers/cps-rum.providers.spec.ts b/projects/cps-telemetry/rum/src/lib/cps-rum.providers/cps-rum.providers.spec.ts new file mode 100644 index 000000000..2dc224609 --- /dev/null +++ b/projects/cps-telemetry/rum/src/lib/cps-rum.providers/cps-rum.providers.spec.ts @@ -0,0 +1,91 @@ +import { ApplicationInitStatus, Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + CpsScenarioTelemetryService, + CpsTelemetrySink, + provideCpsTelemetry +} from 'cps-telemetry'; +import { + CPS_RUM_CREDENTIALS_PROVIDER, + CpsRumBootstrap, + CpsRumCredentialsProvider +} from '../cps-rum-credentials/cps-rum-credentials'; +import { CpsRumTelemetrySink } from '../cps-rum-telemetry.sink/cps-rum-telemetry.sink'; +import { provideCpsTelemetryRumSink } from './cps-rum.providers'; + +jest.mock('aws-rum-web', () => ({ AwsRum: class {} }), { virtual: true }); + +/** Declines every load — the documented session-disable signal. */ +@Injectable() +class StubCredentialsProvider implements CpsRumCredentialsProvider { + async load(): Promise<CpsRumBootstrap | null> { + return null; + } +} + +describe('provideCpsTelemetryRumSink', () => { + function configure(): void { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'my-app', + environment: 'prod', + version: '1.0.0' + }), + provideCpsTelemetryRumSink(), + { + provide: CPS_RUM_CREDENTIALS_PROVIDER, + useClass: StubCredentialsProvider + } + ] + }); + } + + beforeEach(() => configure()); + + it('should replace the default sink with the RUM sink', () => { + expect(TestBed.inject(CpsTelemetrySink)).toBeInstanceOf( + CpsRumTelemetrySink + ); + }); + + it('should resolve the sink token and the concrete class to one instance', () => { + expect(TestBed.inject(CpsTelemetrySink)).toBe( + TestBed.inject(CpsRumTelemetrySink) + ); + }); + + it("should call the sink's init() automatically via app initialization", async () => { + const initSpy = jest.spyOn(CpsRumTelemetrySink.prototype, 'init'); + + await TestBed.inject(ApplicationInitStatus).donePromise; + + expect(initSpy).toHaveBeenCalled(); + initSpy.mockRestore(); + }); + + it('should leave application code unchanged, the same as broadcast/noop', () => { + const scenario = TestBed.inject(CpsScenarioTelemetryService).start({ + name: 'add-to-cart' + }); + + expect(() => scenario.step('one').complete()).not.toThrow(); + expect(scenario.status).toBe('success'); + }); + + it('should report a settled scenario as lost, not silently dropped, once buffered telemetry is flushed at teardown', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + TestBed.inject(CpsScenarioTelemetryService) + .start({ name: 'add-to-cart' }) + .step('one') + .complete(); + + TestBed.resetTestingModule(); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('RUM event(s) lost') + ); + }); +}); diff --git a/projects/cps-telemetry/rum/src/lib/cps-rum.providers/cps-rum.providers.ts b/projects/cps-telemetry/rum/src/lib/cps-rum.providers/cps-rum.providers.ts new file mode 100644 index 000000000..288816e77 --- /dev/null +++ b/projects/cps-telemetry/rum/src/lib/cps-rum.providers/cps-rum.providers.ts @@ -0,0 +1,52 @@ +import { + EnvironmentProviders, + inject, + makeEnvironmentProviders, + provideAppInitializer +} from '@angular/core'; +import { CpsTelemetrySink } from 'cps-telemetry'; +import { CpsRumTelemetrySink } from '../cps-rum-telemetry.sink/cps-rum-telemetry.sink'; + +/** + * Sends telemetry straight to AWS CloudWatch RUM. Requires + * {@link CPS_RUM_CREDENTIALS_PROVIDER}. + * + * Exported from a separate entry point (`cps-telemetry/rum`), not the main + * one, on purpose: `aws-rum-web` is an optional peer dependency, and an + * application that only ever calls `provideCpsTelemetrySink('broadcast' | + * 'noop')` must never be required to install it. Keeping `CpsRumTelemetrySink` + * (and this function) out of the main entry point's module graph is what + * makes that true — a bundler resolves a dynamic `import()`'s specifier at + * build time regardless of whether that branch ever runs, so a static + * import of the RUM sink anywhere in the common providers module would + * force every consumer to have `aws-rum-web` resolvable, not just the ones + * that import this entry point. See DESIGN.md §3, "Entry points". + * + * @example + * ```typescript + * import { provideCpsTelemetryRumSink } from 'cps-telemetry/rum'; + * + * providers: [ + * provideCpsTelemetry({ application: 'cart', environment, version }), + * provideCpsTelemetryRumSink(), + * { provide: CPS_RUM_CREDENTIALS_PROVIDER, useExisting: CartRumCredentials } + * ] + * ``` + * + * @returns providers wiring the RUM sink + * + * @group Utils + */ +export function provideCpsTelemetryRumSink(): EnvironmentProviders { + return makeEnvironmentProviders([ + CpsRumTelemetrySink, + { provide: CpsTelemetrySink, useExisting: CpsRumTelemetrySink }, + provideAppInitializer(() => { + // Not returned, so a slow or hung credential broker doesn't delay + // first paint. + inject(CpsRumTelemetrySink) + .init() + .catch(() => undefined); + }) + ]); +} diff --git a/projects/cps-telemetry/rum/src/public-api.ts b/projects/cps-telemetry/rum/src/public-api.ts new file mode 100644 index 000000000..c2ff3e33b --- /dev/null +++ b/projects/cps-telemetry/rum/src/public-api.ts @@ -0,0 +1,19 @@ +/* + * Public API Surface of cps-telemetry/rum + * + * A separate entry point from `cps-telemetry` itself, so that an + * application using only `provideCpsTelemetrySink('broadcast' | 'noop')` + * never needs `aws-rum-web` (an optional peer dependency) resolvable at + * build time — see `provideCpsTelemetryRumSink`'s own doc comment, and + * DESIGN.md §3, "Entry points". + */ + +export { provideCpsTelemetryRumSink } from './lib/cps-rum.providers/cps-rum.providers'; +export { + CPS_RUM_CREDENTIALS_PROVIDER, + CpsRumAppMonitorConfig, + CpsRumBootstrap, + CpsRumCredentials, + CpsRumCredentialsProvider +} from './lib/cps-rum-credentials/cps-rum-credentials'; +export { CpsRumTelemetrySink } from './lib/cps-rum-telemetry.sink/cps-rum-telemetry.sink'; diff --git a/projects/cps-telemetry/rum/tsconfig.lib.json b/projects/cps-telemetry/rum/tsconfig.lib.json new file mode 100644 index 000000000..f8fd390a8 --- /dev/null +++ b/projects/cps-telemetry/rum/tsconfig.lib.json @@ -0,0 +1,25 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../out-tsc/lib-rum", + "rootDir": "..", + "lib": ["ES2022", "DOM"], + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "skipLibCheck": true, + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts"], + "angularCompilerOptions": { + "extendedDiagnostics": { + "checks": { + "nullishCoalescingNotNullable": "suppress", + "optionalChainNotNullable": "suppress" + } + } + } +} diff --git a/projects/cps-telemetry/rum/tsconfig.spec.json b/projects/cps-telemetry/rum/tsconfig.spec.json new file mode 100644 index 000000000..b8b95e2a0 --- /dev/null +++ b/projects/cps-telemetry/rum/tsconfig.spec.json @@ -0,0 +1,11 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "../../../out-tsc/spec-rum", + "rootDir": "..", + "types": ["jest"], + "resolveJsonModule": true + }, + "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/projects/cps-telemetry/src/lib/config/cps-bi-telemetry.config/cps-bi-telemetry.config.spec.ts b/projects/cps-telemetry/src/lib/config/cps-bi-telemetry.config/cps-bi-telemetry.config.spec.ts new file mode 100644 index 000000000..b278c30e0 --- /dev/null +++ b/projects/cps-telemetry/src/lib/config/cps-bi-telemetry.config/cps-bi-telemetry.config.spec.ts @@ -0,0 +1,11 @@ +import { CPS_DEFAULT_BI_TELEMETRY_CONFIG } from './cps-bi-telemetry.config'; + +describe('CPS_DEFAULT_BI_TELEMETRY_CONFIG', () => { + it('should default the dedup window and key cap', () => { + expect(CPS_DEFAULT_BI_TELEMETRY_CONFIG).toEqual({ + dedupWindowMs: 400, + dedupMaxKeys: 100, + redact: true + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/config/cps-bi-telemetry.config/cps-bi-telemetry.config.ts b/projects/cps-telemetry/src/lib/config/cps-bi-telemetry.config/cps-bi-telemetry.config.ts new file mode 100644 index 000000000..1e2297a64 --- /dev/null +++ b/projects/cps-telemetry/src/lib/config/cps-bi-telemetry.config/cps-bi-telemetry.config.ts @@ -0,0 +1,46 @@ +import { InjectionToken } from '@angular/core'; + +/** + * Business and UX event tracking configuration. + * + * @group Interfaces + */ +export interface CpsBiTelemetryConfig { + /** Milliseconds within which an identical event is treated as a double-fire. */ + dedupWindowMs: number; + + /** + * Distinct event keys tracked before expired ones are swept. + * + * Keyed per `eventName|scenarioId|eventType|feature|metadata`, which + * grows unbounded over a long session without this cap. + */ + dedupMaxKeys: number; + + /** + * Whether redaction runs on BI events. On by default. Turning it off + * skips only the *configurable* PII scrubbing (`extraKeyPatterns`, + * value-pattern scanning, URL-query stripping) — the built-in credential + * denylist, size caps, error normalization and any + * `CpsRedactConfig.extraValueTransforms` still apply; see + * {@link cpsRedactConfigFor}. + */ + redact: boolean; +} + +/** Default BI event tracking settings. */ +export const CPS_DEFAULT_BI_TELEMETRY_CONFIG: CpsBiTelemetryConfig = { + dedupWindowMs: 400, + dedupMaxKeys: 100, + redact: true +}; + +/** + * Resolved BI event configuration. Provided by {@link provideCpsTelemetry}, + * overridden with `withBiEvents(...)`. + * + * @group Tokens + */ +export const CPS_BI_TELEMETRY_CONFIG = new InjectionToken<CpsBiTelemetryConfig>( + 'CPS_BI_TELEMETRY_CONFIG' +); diff --git a/projects/cps-telemetry/src/lib/config/cps-log.config/cps-log.config.spec.ts b/projects/cps-telemetry/src/lib/config/cps-log.config/cps-log.config.spec.ts new file mode 100644 index 000000000..84926e86b --- /dev/null +++ b/projects/cps-telemetry/src/lib/config/cps-log.config/cps-log.config.spec.ts @@ -0,0 +1,15 @@ +import { CPS_DEFAULT_LOG_CONFIG } from './cps-log.config'; + +describe('CPS_DEFAULT_LOG_CONFIG', () => { + it('should keep RUM error mirroring off by default', () => { + expect(CPS_DEFAULT_LOG_CONFIG.mirrorErrorsToRum).toBe(false); + }); + + it('should default the minimum level to log', () => { + expect(CPS_DEFAULT_LOG_CONFIG.minLevel).toBe('log'); + }); + + it('should keep redaction on by default', () => { + expect(CPS_DEFAULT_LOG_CONFIG.redact).toBe(true); + }); +}); diff --git a/projects/cps-telemetry/src/lib/config/cps-log.config/cps-log.config.ts b/projects/cps-telemetry/src/lib/config/cps-log.config/cps-log.config.ts new file mode 100644 index 000000000..290acdc89 --- /dev/null +++ b/projects/cps-telemetry/src/lib/config/cps-log.config/cps-log.config.ts @@ -0,0 +1,62 @@ +import { InjectionToken } from '@angular/core'; +import { + CpsLoggerName, + CpsLogLevel +} from '../../models/cps-log.models/cps-log.models'; + +/** + * Application logging configuration. + * + * @group Interfaces + */ +export interface CpsLogConfig { + /** Records below this severity are discarded. */ + minLevel: CpsLogLevel; + + /** + * Per-logger overrides of {@link minLevel}, keyed by logger name. Loggers + * without an override use {@link minLevel}. + * + * ```typescript + * withLogging({ minLevel: 'warn', levels: { checkout: 'log' } }) + * ``` + */ + levels?: Partial<Record<CpsLoggerName, CpsLogLevel>>; + + /** + * Also report `logger.error` calls to the telemetry sink as RUM errors. Off + * by default; competes for the same session event budget as scenario and + * BI data. + * + * Mirrors `detail.error` when supplied; a message-only call builds a + * synthetic `Error` from the redacted message text instead. + */ + mirrorErrorsToRum: boolean; + + /** + * Whether redaction runs on log records. On by default. Turning it off + * skips only the *configurable* PII scrubbing (`extraKeyPatterns`, + * value-pattern scanning, URL-query stripping) — the built-in credential + * denylist, size caps, error normalization and any + * `CpsRedactConfig.extraValueTransforms` still apply; see + * {@link cpsRedactConfigFor}. + */ + redact: boolean; +} + +/** Default application logging settings. */ +export const CPS_DEFAULT_LOG_CONFIG: CpsLogConfig = { + minLevel: 'log', + mirrorErrorsToRum: false, + redact: true +}; + +/** + * Resolved logging configuration. Provided by {@link provideCpsTelemetry}, + * overridden with `withLogging(...)`. + * + * @group Tokens + */ +export const CPS_LOG_CONFIG = new InjectionToken<CpsLogConfig>( + 'CPS_LOG_CONFIG' +); diff --git a/projects/cps-telemetry/src/lib/config/cps-scenario-telemetry.config/cps-scenario-telemetry.config.spec.ts b/projects/cps-telemetry/src/lib/config/cps-scenario-telemetry.config/cps-scenario-telemetry.config.spec.ts new file mode 100644 index 000000000..fc8c8e4df --- /dev/null +++ b/projects/cps-telemetry/src/lib/config/cps-scenario-telemetry.config/cps-scenario-telemetry.config.spec.ts @@ -0,0 +1,28 @@ +import { CPS_DEFAULT_SCENARIO_TELEMETRY_CONFIG } from './cps-scenario-telemetry.config'; + +describe('CPS_DEFAULT_SCENARIO_TELEMETRY_CONFIG', () => { + it('should keep verbose lifecycle emission off by default', () => { + expect(CPS_DEFAULT_SCENARIO_TELEMETRY_CONFIG.emitLifecycleEvents).toBe( + false + ); + }); + + it('should default to a 30s timeout and a 50-step cap', () => { + expect(CPS_DEFAULT_SCENARIO_TELEMETRY_CONFIG.defaultTimeoutMs).toBe(30_000); + expect(CPS_DEFAULT_SCENARIO_TELEMETRY_CONFIG.maxSteps).toBe(50); + }); + + it('should keep User Timings off by default', () => { + expect(CPS_DEFAULT_SCENARIO_TELEMETRY_CONFIG.userTimings).toBe(false); + }); + + it('should default the mark cleanup fallback to 5 minutes', () => { + expect(CPS_DEFAULT_SCENARIO_TELEMETRY_CONFIG.markCleanupFallbackMs).toBe( + 300_000 + ); + }); + + it('should keep redaction on by default', () => { + expect(CPS_DEFAULT_SCENARIO_TELEMETRY_CONFIG.redact).toBe(true); + }); +}); diff --git a/projects/cps-telemetry/src/lib/config/cps-scenario-telemetry.config/cps-scenario-telemetry.config.ts b/projects/cps-telemetry/src/lib/config/cps-scenario-telemetry.config/cps-scenario-telemetry.config.ts new file mode 100644 index 000000000..7bcbdb884 --- /dev/null +++ b/projects/cps-telemetry/src/lib/config/cps-scenario-telemetry.config/cps-scenario-telemetry.config.ts @@ -0,0 +1,85 @@ +import { InjectionToken } from '@angular/core'; + +/** + * Scenario telemetry configuration. + * + * @group Interfaces + */ +export interface CpsScenarioTelemetryConfig { + /** + * Milliseconds after which an unsettled scenario auto-settles as `timeout`. + * `0` disables the default; individual scenarios can still opt in. + */ + defaultTimeoutMs: number; + + /** + * Emit an event per lifecycle transition instead of one packed event at + * settlement. Off by default. + * + * Counts against the RUM session's shared `sessionEventLimit` (200 by + * default). Suited to local debugging, not production traffic. + * + * Also governs per-step `debugScenario` console output, because the + * console prints one line per event actually sent and nothing else. + * With this off, a scenario prints a single line at settlement — the + * one record it sends. + */ + emitLifecycleEvents: boolean; + + /** + * Maximum number of steps retained per scenario. Further steps are counted in + * `stepCount` but not stored, bounding the payload of a runaway loop. + */ + maxSteps: number; + + /** + * Mirror scenario and step boundaries into `performance.mark` / `measure`, + * visible on the DevTools Performance track. Off by default. + * + * The `debugScenario` LocalStorage flag switches them on regardless of this + * setting. Marks are cleared when a scenario settles; see + * {@link markCleanupFallbackMs} for scenarios that never do. + */ + userTimings: boolean; + + /** + * How long in milliseconds an unsettled scenario's User Timing marks can + * remain in the Performance buffer before a fallback clears them. + * + * Only applies when `userTimings` is enabled and `timeoutMs: 0`. `0` + * disables the fallback, so marks accumulate for the page's lifetime. + */ + markCleanupFallbackMs: number; + + /** + * Whether redaction runs on scenario records. On by default. Turning it + * off skips only the *configurable* PII scrubbing (`extraKeyPatterns`, + * value-pattern scanning, URL-query stripping) — the built-in credential + * denylist, size caps, error normalization and any + * `CpsRedactConfig.extraValueTransforms` still apply; see + * {@link cpsRedactConfigFor}. + */ + redact: boolean; +} + +/** Default scenario telemetry settings. */ +export const CPS_DEFAULT_SCENARIO_TELEMETRY_CONFIG: CpsScenarioTelemetryConfig = + { + defaultTimeoutMs: 30_000, + emitLifecycleEvents: false, + maxSteps: 50, + userTimings: false, + markCleanupFallbackMs: 300_000, + redact: true + }; + +/** + * Resolved scenario telemetry configuration. Provided by + * {@link provideCpsTelemetry}, overridden with `withScenarios(...)`. + * + * @group Tokens + */ +export const CPS_SCENARIO_TELEMETRY_CONFIG = + new InjectionToken<CpsScenarioTelemetryConfig>( + 'CPS_SCENARIO_TELEMETRY_CONFIG' + ); diff --git a/projects/cps-telemetry/src/lib/config/cps-telemetry-common.config/cps-telemetry-common.config.spec.ts b/projects/cps-telemetry/src/lib/config/cps-telemetry-common.config/cps-telemetry-common.config.spec.ts new file mode 100644 index 000000000..f2bd8d516 --- /dev/null +++ b/projects/cps-telemetry/src/lib/config/cps-telemetry-common.config/cps-telemetry-common.config.spec.ts @@ -0,0 +1,20 @@ +import { CPS_DEFAULT_BI_TELEMETRY_CONFIG } from '../cps-bi-telemetry.config/cps-bi-telemetry.config'; +import { CPS_DEFAULT_LOG_CONFIG } from '../cps-log.config/cps-log.config'; +import { CPS_DEFAULT_SCENARIO_TELEMETRY_CONFIG } from '../cps-scenario-telemetry.config/cps-scenario-telemetry.config'; +import { CPS_DEFAULT_TELEMETRY_CONFIG } from './cps-telemetry-common.config'; + +describe('CPS_DEFAULT_TELEMETRY_CONFIG', () => { + it('should default the event namespace to com.cps', () => { + expect(CPS_DEFAULT_TELEMETRY_CONFIG.eventNamespace).toBe('com.cps'); + }); + + it("should compose each concern's own default", () => { + expect(CPS_DEFAULT_TELEMETRY_CONFIG.scenario).toBe( + CPS_DEFAULT_SCENARIO_TELEMETRY_CONFIG + ); + expect(CPS_DEFAULT_TELEMETRY_CONFIG.logs).toBe(CPS_DEFAULT_LOG_CONFIG); + expect(CPS_DEFAULT_TELEMETRY_CONFIG.bi).toBe( + CPS_DEFAULT_BI_TELEMETRY_CONFIG + ); + }); +}); diff --git a/projects/cps-telemetry/src/lib/config/cps-telemetry-common.config/cps-telemetry-common.config.ts b/projects/cps-telemetry/src/lib/config/cps-telemetry-common.config/cps-telemetry-common.config.ts new file mode 100644 index 000000000..e2733ab66 --- /dev/null +++ b/projects/cps-telemetry/src/lib/config/cps-telemetry-common.config/cps-telemetry-common.config.ts @@ -0,0 +1,84 @@ +import { InjectionToken } from '@angular/core'; +import { + CpsBiTelemetryConfig, + CPS_DEFAULT_BI_TELEMETRY_CONFIG +} from '../cps-bi-telemetry.config/cps-bi-telemetry.config'; +import { + CpsLogConfig, + CPS_DEFAULT_LOG_CONFIG +} from '../cps-log.config/cps-log.config'; +import { + CpsScenarioTelemetryConfig, + CPS_DEFAULT_SCENARIO_TELEMETRY_CONFIG +} from '../cps-scenario-telemetry.config/cps-scenario-telemetry.config'; +import { CPS_DEFAULT_EVENT_NAMESPACE } from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { + CPS_DEFAULT_REDACT_CONFIG, + CpsRedactConfig +} from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; + +/** + * The identity every telemetry record and event carries — application name, + * deployment environment and version, plus the event-type namespace. + * + * @group Interfaces + */ +export interface CpsTelemetryIdentity { + /** Application name, e.g. `composition`. Becomes a session attribute. */ + application: string; + + /** Deployment environment, e.g. `dev`, `qa`, `prod`. */ + environment: string; + + /** Application version, e.g. `22.0.0`. */ + version: string; + + /** + * Prefix for the custom event types this library emits — `{namespace}.scenario`, + * `{namespace}.scenario.step` and `{namespace}.bi`. + * + * Defaults to `com.cps`. + */ + eventNamespace?: string; +} + +/** + * Library defaults for every concern not carried by {@link CpsTelemetryIdentity} + * — applied by {@link provideCpsTelemetry} to anything not overridden through a + * `with*()` feature. + * + * @group Utils + */ +export const CPS_DEFAULT_TELEMETRY_CONFIG: { + eventNamespace: string; + scenario: CpsScenarioTelemetryConfig; + logs: CpsLogConfig; + bi: CpsBiTelemetryConfig; + redact: CpsRedactConfig; +} = { + eventNamespace: CPS_DEFAULT_EVENT_NAMESPACE, + scenario: CPS_DEFAULT_SCENARIO_TELEMETRY_CONFIG, + logs: CPS_DEFAULT_LOG_CONFIG, + bi: CPS_DEFAULT_BI_TELEMETRY_CONFIG, + redact: CPS_DEFAULT_REDACT_CONFIG +}; + +/** + * The application's identity — shared by every concern; see + * {@link CpsTelemetryIdentity}. + * + * @group Tokens + */ +export const CPS_TELEMETRY_IDENTITY = new InjectionToken<CpsTelemetryIdentity>( + 'CPS_TELEMETRY_IDENTITY' +); + +/** + * Resolved redaction configuration, shared by every concern. Provided by + * {@link provideCpsTelemetry}, overridden with `withRedaction(...)`. + * + * @group Tokens + */ +export const CPS_REDACT_CONFIG = new InjectionToken<CpsRedactConfig>( + 'CPS_REDACT_CONFIG' +); diff --git a/projects/cps-telemetry/src/lib/models/cps-bi.models/cps-bi.models.ts b/projects/cps-telemetry/src/lib/models/cps-bi.models/cps-bi.models.ts new file mode 100644 index 000000000..e5c93c772 --- /dev/null +++ b/projects/cps-telemetry/src/lib/models/cps-bi.models/cps-bi.models.ts @@ -0,0 +1,96 @@ +import { + CpsRegistered, + CpsTelemetryMetadata +} from '../cps-telemetry-common.models/cps-telemetry-common.models'; + +/** + * Registry of this application's business event names. + * + * An event name is a metric dimension: a typo does not produce a wrong + * figure, it silently starts a second, incomplete series alongside the one + * the dashboard reads. Declaring the vocabulary turns that into a compile + * error. + * + * @example + * ```typescript + * // src/app/telemetry.schema.ts + * declare module 'cps-telemetry' { + * interface CpsBiEventNames { + * export_clicked: true; + * theme_changed: true; + * } + * } + * export {}; + * ``` + * + * @group Interfaces + */ +// Empty by design — see CpsScenarioNames in cps-scenario.models.ts. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface CpsBiEventNames {} + +/** + * Every business event name this application declares. + * + * Resolves to `string` until {@link CpsBiEventNames} is augmented. + * + * @group Types + */ +export type CpsBiEventName = CpsRegistered<CpsBiEventNames>; + +/** + * Correlation fields carried through unchanged onto the emitted + * {@link CpsBiEvent} — shared by {@link CpsBiEventDetail} and + * {@link CpsBiEvent} so neither declares its own copy. + * + * @group Interfaces + */ +export interface CpsBiEventCorrelation { + /** Scenario this event happened inside. Pass {@link CpsScenario.id}. */ + scenarioId?: string; + + /** Product area, e.g. `customers`. Scrubbed the same as any other string value. */ + feature?: string; +} + +/** + * Optional correlation detail for a business/UX event. + * + * @group Interfaces + */ +export interface CpsBiEventDetail extends CpsBiEventCorrelation { + /** + * Overrides the RUM event type for this one event. BI events normally + * share one type, with `eventName` as a field. Use this only when an + * existing dashboard or metric filter needs a specific event type. + */ + eventType?: string; +} + +/** + * A discrete business or UX event. + * + * Browser, device and page attributes come from the RUM envelope and aren't + * repeated here — the client already stamps `pageId`/`pageUrl` on every + * event, so a `route` field would just duplicate that. `application` is + * carried anyway, for a self-describing record; see {@link CpsScenarioRecord}. + * + * @group Interfaces + */ +export interface CpsBiEvent extends CpsBiEventCorrelation { + /** + * Event name, e.g. `export_clicked`. Declared by the application in + * {@link CpsBiEventNames} — this library never hardcodes business event + * names. + */ + eventName: CpsBiEventName; + + /** ISO-8601 timestamp. */ + eventTime: string; + + /** Redacted structured attributes. */ + metadata?: CpsTelemetryMetadata; + + /** Application name, from {@link CpsTelemetryIdentity.application}. */ + application: string; +} diff --git a/projects/cps-telemetry/src/lib/models/cps-log.models/cps-log.models.ts b/projects/cps-telemetry/src/lib/models/cps-log.models/cps-log.models.ts new file mode 100644 index 000000000..a60f87eba --- /dev/null +++ b/projects/cps-telemetry/src/lib/models/cps-log.models/cps-log.models.ts @@ -0,0 +1,149 @@ +import { + CpsRegistered, + CpsTelemetryError, + CpsTelemetryMetadata +} from '../cps-telemetry-common.models/cps-telemetry-common.models'; + +/** + * Registry of this application's logger names. + * + * A logger name says which part of the application a record came from, and + * selects its per-logger level. Declaring the vocabulary turns a typo'd + * name into a compile error instead of a filter that silently never matches. + * + * @example + * ```typescript + * // src/app/telemetry.schema.ts + * declare module 'cps-telemetry' { + * interface CpsLoggerNames { + * checkout: true; + * admin: true; + * } + * } + * export {}; + * ``` + * + * @group Interfaces + */ +// Empty by design — see CpsScenarioNames in cps-scenario.models.ts. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface CpsLoggerNames {} + +/** + * Every logger name this application declares. + * + * Resolves to `string` until {@link CpsLoggerNames} is augmented. + * + * @group Types + */ +export type CpsLoggerName = CpsRegistered<CpsLoggerNames>; + +/** + * Severity of a log record. Ordered: `log` < `warn` < `error`. + * + * @group Types + */ +export type CpsLogLevel = 'log' | 'warn' | 'error'; + +/** + * Numeric ordering used to apply {@link CpsLogConfig.minLevel}. + * + * @group Types + */ +export const CPS_LOG_LEVEL_ORDER: Record<CpsLogLevel, number> = { + log: 0, + warn: 1, + error: 2 +}; + +/** + * Optional per-call detail accepted by the logger. + * + * Every field is optional — `logger.log('message')` is a valid call. + * + * @group Interfaces + */ +export interface CpsLogDetail { + /** + * Free-form subsystem label, e.g. a service or component name, for + * filtering logs without parsing the message. Scrubbed like `message`. + */ + context?: string; + + /** Structured attributes. Redacted before leaving the browser. */ + metadata?: CpsTelemetryMetadata; + + /** Thrown value. Normalized via {@link cpsNormalizeError}. */ + error?: unknown; + + /** + * Identifier joining this log line to other telemetry. Pass a + * {@link CpsScenario.id} here to tie this line to a scenario, and the + * same id reaches {@link CpsLoggerService.query} to read the journey + * back across every logger that wrote during it. + */ + correlationId?: string; +} + +/** + * The structured record handed to the application's {@link CpsLogApiProvider}. + * + * @group Interfaces + */ +export interface CpsLogRecord { + /** ISO-8601 timestamp of the moment the log call was made. */ + timestamp: string; + + /** Severity. */ + level: CpsLogLevel; + + /** Human-readable message. Redacted and length-capped. */ + message: string; + + /** Named logger this record came from, when it came from one. */ + logger?: CpsLoggerName; + + /** Subsystem label, when supplied. Redacted and length-capped, like `message`. */ + context?: string; + + /** Redacted structured attributes, when supplied. */ + metadata?: CpsTelemetryMetadata; + + /** Normalized error, when supplied. */ + error?: CpsTelemetryError; + + /** Correlation identifier, when supplied. */ + correlationId?: string; + + /** Application name. */ + application: string; + + /** Deployment environment. */ + environment: string; + + /** Application version. */ + version: string; + + /** Application-supplied user identifier, when known. */ + userId?: string; + + /** Session identifier reported by the sink, when available. */ + sessionId?: string; +} + +/** + * The application-facing logging API — what + * {@link CpsLoggerService.getLogger} hands back, so application code holds + * this contract without naming the service. + * + * An instance *is* its name. The name is fixed when the logger is created + * and stamped on every record it writes, so a record's `logger` can never + * disagree with the logger that produced it. + * + * @group Interfaces + */ +export interface CpsLogger { + log(message: string, detail?: CpsLogDetail): void; + warn(message: string, detail?: CpsLogDetail): void; + error(message: string, detail?: CpsLogDetail): void; +} diff --git a/projects/cps-telemetry/src/lib/models/cps-scenario.models/cps-scenario.models.ts b/projects/cps-telemetry/src/lib/models/cps-scenario.models/cps-scenario.models.ts new file mode 100644 index 000000000..6134b6431 --- /dev/null +++ b/projects/cps-telemetry/src/lib/models/cps-scenario.models/cps-scenario.models.ts @@ -0,0 +1,372 @@ +import { + CpsRegistered, + CpsTelemetryError, + CpsTelemetryMetadata +} from '../cps-telemetry-common.models/cps-telemetry-common.models'; + +/** + * Registry of this application's scenario names. + * + * Empty in the library. Augment it from your application, and every + * `start({ name })` call is checked against it from then on. + * + * @example + * ```typescript + * // src/app/telemetry/scenarios.schema.ts + * declare module 'cps-telemetry' { + * interface CpsScenarioNames { + * 'route-navigation': true; + * 'load-dashboard': true; + * } + * } + * export {}; + * ``` + * + * @group Interfaces + */ +// Empty by design: the application fills it in. See the doc comment above. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface CpsScenarioNames {} + +/** + * Registry of this application's step and aggregate names. + * + * Steps and aggregates share one vocabulary — a name declared for a step is + * also valid passed to `aggregateStart`. + * + * @example + * ```typescript + * // src/app/telemetry/scenarios.schema.ts + * declare module 'cps-telemetry' { + * interface CpsScenarioSteps { + * 'resolve-route': true; + * 'activate': true; + * } + * } + * export {}; + * ``` + * + * @group Interfaces + */ +// Empty by design — see CpsScenarioNames above. +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface CpsScenarioSteps {} + +/** + * Every scenario name this application declares. + * + * Resolves to `string` until {@link CpsScenarioNames} is augmented. + * + * @group Types + */ +export type CpsScenarioName = CpsRegistered<CpsScenarioNames>; + +/** + * Every step and aggregate name this application declares. + * + * Resolves to `string` until {@link CpsScenarioSteps} is augmented. + * + * @group Types + */ +export type CpsStepName = CpsRegistered<CpsScenarioSteps>; + +/** + * Lifecycle state of a scenario. + * + * - `success` — the journey reached its goal. + * - `failure` — a defect. Investigate. + * - `abandoned` — the user navigated away or the page went away. + * `metadata.abandonedBy` (`'caller'` or `'page-hidden'`) says which. + * - `incomplete` — an expected path that didn't reach the goal: no results, + * a declined guard, a feature flag routing elsewhere. Not an error. + * - `timeout` — the scenario never settled within its deadline. + * + * There is no "in progress" status — {@link CpsScenario.status} is + * `undefined` until it settles, and {@link CpsScenario.isSettled} is derived + * from that. Settling an already-settled scenario is a no-op, never a throw. + * + * @group Types + */ +export type CpsScenarioStatus = + 'success' | 'failure' | 'abandoned' | 'incomplete' | 'timeout'; + +/** + * Outcome of a single step — the same union as the scenario itself, since a + * step still open when its scenario settles inherits that settlement. + * + * @group Types + */ +export type CpsScenarioStepStatus = CpsScenarioStatus; + +/** + * Detail accepted when closing a step. + * + * @group Interfaces + */ +export interface CpsScenarioStepDetail { + /** Optional human-readable note. */ + message?: string; + + /** + * Short, structured explanation, e.g. `'cache-hit'`, `'no-results'` — a + * stable, low-cardinality value for grouping and filtering, independent + * of `message`. + */ + reason?: string; + + /** Attributes merged into the resulting record. */ + metadata?: CpsTelemetryMetadata; +} + +/** + * One measured phase within a scenario. + * + * Offsets are milliseconds relative to the scenario start rather than absolute + * epochs — they stay small integers, which keeps the packed step array cheap to + * serialize and read. + * + * @group Interfaces + */ +export interface CpsScenarioStep extends CpsScenarioStepDetail { + /** + * Step name, e.g. `fetch-data`. Declared in {@link CpsScenarioSteps} — + * except the two boundary markers every scenario carries automatically, + * `scenario-start` and `scenario-end`, which need no declaration because + * the library writes them itself. See {@link CpsScenarioRecord.steps}. + */ + name: CpsStepName | 'scenario-start' | 'scenario-end'; + + /** Milliseconds from scenario start to step start. */ + startOffset: number; + + /** Milliseconds from scenario start to step end. Absent while open. */ + endOffset?: number; + + /** Step duration in milliseconds. Absent while open. */ + stepDelta?: number; + + /** + * Milliseconds since the host page loaded (`performance.timeOrigin`), at + * the moment this step closed — a timeline position, not a duration. + * Consistent across a composed page's realms; see `cpsElapsedNow`. + */ + elapsed?: number; + + /** Outcome. Absent while the step is still open. */ + status?: CpsScenarioStepStatus; + + /** Normalized error, when the step failed. */ + error?: CpsTelemetryError; +} + +/** + * The per-step event, sent under `{namespace}.scenario.step` when + * {@link CpsScenarioTelemetryConfig.emitLifecycleEvents} is on. + * + * One closed step, plus enough of its scenario's identity to stand alone — + * the packed {@link CpsScenarioRecord} carries the same step inside `steps`. + * + * @group Interfaces + */ +export interface CpsScenarioStepEvent extends CpsScenarioStep { + /** The owning scenario's id. */ + scenarioId: string; + + /** The owning scenario's name. */ + scenarioName: CpsScenarioName; + + /** The emitting application. */ + application: string; + + /** Session identifier reported by the sink, when available. */ + sessionId?: string; + + /** User identifier reported by the sink, when available. */ + userId?: string; +} + +/** + * Total time spent across repeated calls of one operation — a formatter + * called per row, a validator called per field — where the total matters + * more than a hundred individual steps. + * + * @group Interfaces + */ +export interface CpsScenarioAggregate { + /** Operation name. Declared in {@link CpsScenarioSteps}. */ + name: CpsStepName; + + /** Summed duration across every completed call, in milliseconds. */ + elapsed: number; + + /** Number of completed calls contributing to `elapsed`. */ + callCount: number; +} + +/** + * Fields fixed at {@link CpsScenario.start} that identify the journey and + * its place in a larger one, carried through onto the emitted + * {@link CpsScenarioRecord}. + * + * @group Interfaces + */ +export interface CpsScenarioIdentityDetail { + /** Product area, e.g. `customers`. */ + feature?: string; + + /** Operation discriminator within the feature, e.g. `export`. */ + operation?: string; + + /** + * Route the journey started from, as a **template** — `/customers/:id`, + * never a resolved `/customers/john@example.com`. Captured at `start()`, + * so it reflects where the user began, not where a later navigation left + * them. A resolved URL still has its query string stripped, but that's a + * safety net, not a substitute — it would still split one metric + * dimension into one series per customer. + */ + route?: string; + + /** + * Identifier of an enclosing scenario, for nested journeys. Enables + * parent/child reconstruction in CloudWatch. + */ + parentScenarioId?: string; +} + +/** + * Options accepted when starting a scenario. + * + * @group Interfaces + */ +export interface CpsScenarioOptions extends CpsScenarioIdentityDetail { + /** + * Stable scenario name, declared in {@link CpsScenarioNames}. A metric + * dimension — never interpolate an id into it. + */ + name: CpsScenarioName; + + /** + * Milliseconds after which the scenario auto-settles as `timeout`. Defaults + * to {@link CpsScenarioTelemetryConfig.defaultTimeoutMs}. Pass `0` to disable. + */ + timeoutMs?: number; + + /** + * Backdates the scenario start, in **epoch milliseconds** (`Date.now()`) — + * useful when the journey begins before the code measuring it runs, e.g. + * at a click rather than in the async handler it reaches. Clamped to the + * page's lifetime; an out-of-range value is ignored. + */ + startedAt?: number; + + /** Attributes applied to the scenario record and to every emitted event. */ + metadata?: CpsTelemetryMetadata; +} + +/** + * Detail accepted when settling a scenario. + * + * @group Interfaces + */ +export interface CpsScenarioOutcome extends CpsScenarioStepDetail { + /** + * Result code — an HTTP status, or a business error code. Kept separate from + * `error` so it can serve as a low-cardinality metric dimension for + * error-category distribution. + */ + statusCode?: string | number; + + /** The thrown value, for {@link CpsScenario.fail}. Normalized before emission. */ + error?: unknown; +} + +/** + * The complete scenario payload emitted to the telemetry sink. + * + * Fields the AWS RUM client already attaches to every event — browser, OS, + * device, page — stay absent to avoid duplicating the RUM envelope. + * `application`, `sessionId` and `userId` are carried anyway, so a record + * is self-describing without cross-referencing the envelope. + * + * @group Interfaces + */ +export interface CpsScenarioRecord + extends Omit<CpsScenarioOutcome, 'error'>, CpsScenarioIdentityDetail { + /** Unique scenario identifier. Doubles as the correlation id. */ + scenarioId: string; + + /** Scenario name, from {@link CpsScenarioOptions.name}. */ + scenarioName: CpsScenarioName; + + /** + * Lifecycle state at the moment of emission. `undefined` only for a + * snapshot taken via {@link CpsScenario.toRecord} while still running. + */ + status?: CpsScenarioStatus; + + /** Normalized error, when the scenario failed. */ + error?: CpsTelemetryError; + + /** ISO-8601 timestamp at scenario start. */ + startTime: string; + + /** ISO-8601 timestamp at settlement. Absent while in progress. */ + endTime?: string; + + /** + * Total scenario duration in milliseconds — the headline latency measure. + * + * Named `delta`, not `elapsed`: this record's `elapsed` field means + * something else — see below. + */ + delta: number; + + /** + * Milliseconds since the host page loaded (`performance.timeOrigin`), at + * the moment this record was built — a timeline position, not a duration. + * Consistent across a composed page's realms; see `cpsElapsedNow`. Not + * exact for the RUM session itself, since the session cookie can survive a + * reload that resets `performance.timeOrigin`. + */ + elapsed: number; + + /** Number of steps the caller declared, including any dropped past `maxSteps`. */ + stepCount: number; + + /** + * Every step, in the order they were opened. The library adds two + * boundary markers — `scenario-start` and `scenario-end` — that bookend + * the real steps and don't count toward `stepCount`/`maxSteps`. A + * mid-flight {@link CpsScenario.toRecord} snapshot may hold only + * `scenario-start`. + */ + steps: CpsScenarioStep[]; + + /** + * Set when `stepCount` exceeded `maxSteps` and `steps` was truncated, so a + * consumer need not know the configured limit to spot a partial list. + */ + exceededStepsLimit?: boolean; + + /** Name of the last step closed before the scenario settled. */ + previousStep?: CpsStepName; + + /** Totals recorded via {@link CpsScenario.aggregateStart}. */ + aggregates?: CpsScenarioAggregate[]; + + /** Application name, from {@link CpsTelemetryIdentity.application}. */ + application: string; + + /** + * Session identifier from the active telemetry sink, when it has one. + * `undefined` before the RUM client finishes initializing. + */ + sessionId?: string; + + /** + * Application user identifier, when one is signed in. Never an email, + * username or account number — see {@link CpsTelemetrySink.setUserId}. + */ + userId?: string; +} diff --git a/projects/cps-telemetry/src/lib/models/cps-telemetry-common.models/cps-telemetry-common.models.spec.ts b/projects/cps-telemetry/src/lib/models/cps-telemetry-common.models/cps-telemetry-common.models.spec.ts new file mode 100644 index 000000000..a20981b15 --- /dev/null +++ b/projects/cps-telemetry/src/lib/models/cps-telemetry-common.models/cps-telemetry-common.models.spec.ts @@ -0,0 +1,36 @@ +import { + CPS_DEFAULT_EVENT_NAMESPACE, + CPS_TELEMETRY_EVENT_TYPE, + cpsEventTypes +} from './cps-telemetry-common.models'; + +describe('cpsEventTypes', () => { + it('should derive the three types from the default namespace', () => { + expect(cpsEventTypes()).toEqual({ + scenario: 'com.cps.scenario', + scenarioStep: 'com.cps.scenario.step', + bi: 'com.cps.bi' + }); + }); + + it('should let an application keep its own namespace', () => { + expect(cpsEventTypes('com.test-app')).toEqual({ + scenario: 'com.test-app.scenario', + scenarioStep: 'com.test-app.scenario.step', + bi: 'com.test-app.bi' + }); + }); + + it.each([[''], [undefined]])( + 'should fall back to the default namespace for %p', + (value) => { + expect(cpsEventTypes(value).bi).toBe(`${CPS_DEFAULT_EVENT_NAMESPACE}.bi`); + } + ); + + it('should expose the default namespace as a constant', () => { + expect(CPS_TELEMETRY_EVENT_TYPE).toEqual( + cpsEventTypes(CPS_DEFAULT_EVENT_NAMESPACE) + ); + }); +}); diff --git a/projects/cps-telemetry/src/lib/models/cps-telemetry-common.models/cps-telemetry-common.models.ts b/projects/cps-telemetry/src/lib/models/cps-telemetry-common.models/cps-telemetry-common.models.ts new file mode 100644 index 000000000..9fede9ce8 --- /dev/null +++ b/projects/cps-telemetry/src/lib/models/cps-telemetry-common.models/cps-telemetry-common.models.ts @@ -0,0 +1,99 @@ +/** + * Attribute bag accepted by every telemetry API in this library. + * + * Flat and primitive-only — the type system alone keeps a response body, DOM + * node, or populated user object out of telemetry. Matches what AWS RUM + * accepts as event metadata, so nothing needs converting at the sink. + * + * @group Types + */ +export type CpsTelemetryMetadata = Record< + string, + string | number | boolean | null +>; + +/** + * Normalized, size-bounded representation of a thrown value. + * + * Produced by {@link cpsNormalizeError}. Raw `Error` objects (and HTTP + * client errors, which can carry whole request/response bodies) are never + * placed into a telemetry payload directly. + * + * @group Interfaces + */ +export interface CpsTelemetryError { + /** Constructor name, e.g. `TypeError`, `HttpErrorResponse`. */ + name: string; + + /** Redacted, length-capped error message. */ + message: string; + + /** Length-capped stack trace. Omitted when stack capture is disabled. */ + stack?: string; +} + +/** + * The keys of a registry, or `string` while it is still empty — so an + * application with no schema yet gets `string`, not `never`. Names are + * unconstrained until the first augmentation, then checked against it. + * + * Shared by every closed-vocabulary name type in this library (scenario, + * step, logger and business event names). + */ +export type CpsRegistered<TRegistry> = keyof TRegistry extends never + ? string + : keyof TRegistry; + +/** + * Prefix used for event types when the application does not choose its own. + * + * @group Utils + */ +export const CPS_DEFAULT_EVENT_NAMESPACE = 'com.cps'; + +/** + * The custom event types this library emits. + * + * Scenario and BI telemetry each use a single type carrying a `status` / + * `eventName` field, rather than one type per event — one type means one + * schema to query and one extended-metric definition in CloudWatch. + * + * @group Interfaces + */ +export interface CpsTelemetryEventTypes { + /** A settled scenario, with its steps packed into the payload. */ + scenario: string; + + /** An individual scenario step. Only emitted in verbose mode. */ + scenarioStep: string; + + /** A business or UX event. */ + bi: string; +} + +/** + * Derives the event types for a namespace, so an application migrating onto + * this library can keep its existing namespace. + * + * @param namespace the prefix, e.g. `com.my-app` + * @returns the three event types under that namespace + * + * @group Utils + */ +export function cpsEventTypes( + namespace: string = CPS_DEFAULT_EVENT_NAMESPACE +): CpsTelemetryEventTypes { + const prefix = namespace || CPS_DEFAULT_EVENT_NAMESPACE; + return { + scenario: `${prefix}.scenario`, + scenarioStep: `${prefix}.scenario.step`, + bi: `${prefix}.bi` + }; +} + +/** + * The event types under the default namespace. + * + * @group Utils + */ +export const CPS_TELEMETRY_EVENT_TYPE = cpsEventTypes(); diff --git a/projects/cps-telemetry/src/lib/models/cps-telemetry-monitor.models/cps-telemetry-monitor.models.ts b/projects/cps-telemetry/src/lib/models/cps-telemetry-monitor.models/cps-telemetry-monitor.models.ts new file mode 100644 index 000000000..1db6063c8 --- /dev/null +++ b/projects/cps-telemetry/src/lib/models/cps-telemetry-monitor.models/cps-telemetry-monitor.models.ts @@ -0,0 +1,125 @@ +import { CpsBiEvent } from '../cps-bi.models/cps-bi.models'; +import { CpsLogRecord } from '../cps-log.models/cps-log.models'; +import { + CpsScenarioRecord, + CpsScenarioStepEvent +} from '../cps-scenario.models/cps-scenario.models'; +import { CpsTelemetryError } from '../cps-telemetry-common.models/cps-telemetry-common.models'; + +/** + * Any JSON value — used where a payload's shape is not guaranteed, such as + * an event forwarded from another realm. + * + * @group Types + */ +export type CpsJsonValue = + | string + | number + | boolean + | null + | CpsJsonValue[] + | { [key: string]: CpsJsonValue }; + +/** + * A JSON object — see {@link CpsJsonValue}. + * + * @group Types + */ +export type CpsJsonObject = { [key: string]: CpsJsonValue }; + +/** + * Where an observed event was handed: the telemetry sink, or the + * application's log API provider. + * + * @group Types + */ +export type CpsTelemetryDestination = 'sink' | 'log-provider'; + +/** + * Whether an observed event was produced in this realm, or forwarded from + * another realm through {@link CpsTelemetryBroadcastHost}. + * + * @group Types + */ +export type CpsTelemetryEventOrigin = + { forwarded: false } | { forwarded: true; application?: string }; + +/** + * What an observed event is. + * + * - `bi`, `scenario`, `scenario-step` — custom events sent to the sink. + * - `log` — a record sent to the log API provider. + * - `error` — an error sent to the sink, e.g. a log error mirrored to RUM. + * - `unknown` — a forwarded event whose type this realm does not recognise. + * + * @group Types + */ +export type CpsTelemetryEventKind = + 'bi' | 'scenario' | 'scenario-step' | 'log' | 'error' | 'unknown'; + +interface CpsTelemetryObservedBase { + /** Increases by one per observed event, within one page load. */ + sequence: number; + + /** When the event was handed over, as ISO-8601. */ + capturedAt: string; + + destination: CpsTelemetryDestination; + origin: CpsTelemetryEventOrigin; +} + +/** + * One event handed to a destination, as seen by + * {@link CpsTelemetryMonitor}. + * + * Local payloads keep their library types. A forwarded payload arrived from + * another realm and was only shape-checked there, so it is typed as plain + * JSON — narrow on `origin.forwarded` before relying on its fields. + * + * @group Types + */ +export type CpsTelemetryObservedEvent = + | (CpsTelemetryObservedBase & { + kind: 'bi'; + eventType: string; + payload: CpsBiEvent | CpsJsonObject; + }) + | (CpsTelemetryObservedBase & { + kind: 'scenario'; + eventType: string; + payload: CpsScenarioRecord | CpsJsonObject; + }) + | (CpsTelemetryObservedBase & { + kind: 'scenario-step'; + eventType: string; + payload: CpsScenarioStepEvent | CpsJsonObject; + }) + | (CpsTelemetryObservedBase & { + kind: 'log'; + payload: CpsLogRecord; + }) + | (CpsTelemetryObservedBase & { + kind: 'error'; + payload: CpsTelemetryError; + /** The log event this error was mirrored from, when there is one. */ + relatedSequence?: number; + }) + | (CpsTelemetryObservedBase & { + kind: 'unknown'; + eventType: string; + payload: CpsJsonObject; + }); + +/** Distributes `Omit` over a union, so each member keeps its own fields. */ +type CpsOmitEach<T, K extends PropertyKey> = T extends unknown + ? Omit<T, K> + : never; + +/** + * What a hand-off site passes to {@link CpsTelemetryMonitor.publish} — an + * observed event without the fields the monitor assigns itself. + */ +export type CpsTelemetryPublishInput = CpsOmitEach< + CpsTelemetryObservedEvent, + 'sequence' | 'capturedAt' +>; diff --git a/projects/cps-telemetry/src/lib/providers/cps-broadcast-log-api.provider/cps-broadcast-log-api.provider.spec.ts b/projects/cps-telemetry/src/lib/providers/cps-broadcast-log-api.provider/cps-broadcast-log-api.provider.spec.ts new file mode 100644 index 000000000..7a97b1259 --- /dev/null +++ b/projects/cps-telemetry/src/lib/providers/cps-broadcast-log-api.provider/cps-broadcast-log-api.provider.spec.ts @@ -0,0 +1,272 @@ +import { NgZone } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CpsLogRecord } from '../../models/cps-log.models/cps-log.models'; +import { CPS_BROADCAST_CHANNEL } from '../../sinks/cps-broadcast/cps-broadcast.messages'; +import { + CPS_BROADCAST_LOG_QUERY_TIMEOUT_MS, + CpsBroadcastLogApiProvider +} from './cps-broadcast-log-api.provider'; + +/** + * Stands in for `BroadcastChannel`: keeps what the provider posts, and lets + * a test deliver a message to it as the host would. + */ +class ChannelStub { + static instances = [] as ChannelStub[]; + + onmessage: ((event: { data: unknown }) => void) | null = null; + readonly posted: unknown[] = []; + closed = false; + + constructor(readonly name: string) { + ChannelStub.instances.push(this); + } + + static install(): void { + ChannelStub.instances = []; + Object.defineProperty(globalThis, 'BroadcastChannel', { + value: ChannelStub, + configurable: true, + writable: true + }); + } + + static uninstall(): void { + delete (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel; + } + + postMessage(message: unknown): void { + this.posted.push(message); + } + + close(): void { + this.closed = true; + } + + deliver(data: unknown): void { + this.onmessage?.({ data }); + } +} + +const record: CpsLogRecord = { + timestamp: '2026-01-01T00:00:00.000Z', + level: 'warn', + message: 'Careful now', + logger: 'cart', + application: 'cart', + environment: 'test', + version: '1.0.0' +}; + +describe('CpsBroadcastLogApiProvider', () => { + let provider: CpsBroadcastLogApiProvider; + let channel: ChannelStub; + + /** The id of the latest query the provider posted. */ + function lastQueryId(): string { + const queries = channel.posted.filter( + (m) => (m as { kind: string }).kind === 'log-query' + ); + return (queries[queries.length - 1] as { id: string }).id; + } + + function create(): void { + TestBed.configureTestingModule({ providers: [CpsBroadcastLogApiProvider] }); + provider = TestBed.inject(CpsBroadcastLogApiProvider); + channel = ChannelStub.instances[0]; + } + + beforeEach(() => { + ChannelStub.install(); + TestBed.resetTestingModule(); + }); + + afterEach(() => { + ChannelStub.uninstall(); + jest.useRealTimers(); + }); + + describe('with a channel', () => { + beforeEach(() => create()); + + it('should open the default channel', () => { + expect(channel.name).toBe('cps-telemetry'); + }); + + it('should post each record as it is sent', () => { + provider.send(record); + + expect(channel.posted).toEqual([{ kind: 'log', record }]); + }); + + it('should post a flush request', () => { + provider.flush(); + + expect(channel.posted).toEqual([{ kind: 'log-flush' }]); + }); + + it('should post a query with a fresh id and the filter', () => { + provider.query({ correlationId: 'c-1' }); + provider.query({ correlationId: 'c-1' }); + + const [first, second] = channel.posted as { + kind: string; + id: string; + filter: unknown; + }[]; + expect(first).toEqual({ + kind: 'log-query', + id: expect.any(String), + filter: { correlationId: 'c-1' } + }); + expect(second.id).not.toBe(first.id); + }); + + it('should resolve a query with the answer to its id', async () => { + const pending = provider.query({}); + + channel.deliver({ + kind: 'log-query-result', + id: lastQueryId(), + records: [record] + }); + + await expect(pending).resolves.toEqual([record]); + }); + + it('should keep only well-formed records from the answer', async () => { + const pending = provider.query({}); + + channel.deliver({ + kind: 'log-query-result', + id: lastQueryId(), + records: [record, { junk: true }, { ...record, level: 'verbose' }] + }); + + await expect(pending).resolves.toEqual([record]); + }); + + it('should ignore an answer to another id, and anything that is not an answer', async () => { + jest.useFakeTimers(); + let result: CpsLogRecord[] | undefined; + provider.query({}).then((records) => (result = records)); + + channel.deliver({ + kind: 'log-query-result', + id: 'someone-else', + records: [record] + }); + channel.deliver({ kind: 'identity', sessionId: 's' }); + channel.deliver('not a message'); + await Promise.resolve(); + expect(result).toBeUndefined(); + + channel.deliver({ + kind: 'log-query-result', + id: lastQueryId(), + records: [] + }); + await Promise.resolve(); + expect(result).toEqual([]); + }); + + it('should resolve an unanswered query to [] once the timeout passes, not before', async () => { + jest.useFakeTimers(); + let result: CpsLogRecord[] | undefined; + provider.query({}).then((records) => (result = records)); + + await jest.advanceTimersByTimeAsync( + CPS_BROADCAST_LOG_QUERY_TIMEOUT_MS - 1 + ); + expect(result).toBeUndefined(); + + await jest.advanceTimersByTimeAsync(1); + expect(result).toEqual([]); + }); + + it('should ignore an answer that arrives after the timeout', async () => { + jest.useFakeTimers(); + const pending = provider.query({}); + const id = lastQueryId(); + + await jest.advanceTimersByTimeAsync(CPS_BROADCAST_LOG_QUERY_TIMEOUT_MS); + channel.deliver({ kind: 'log-query-result', id, records: [record] }); + + await expect(pending).resolves.toEqual([]); + }); + + it('should resolve unanswered queries to [] and close the channel when destroyed', async () => { + const first = provider.query({}); + const second = provider.query({}); + + TestBed.resetTestingModule(); + + await expect(first).resolves.toEqual([]); + await expect(second).resolves.toEqual([]); + expect(channel.closed).toBe(true); + }); + + it('should resolve a query made after destruction to [] at once, posting nothing', async () => { + provider.ngOnDestroy(); + const postedBefore = channel.posted.length; + + await expect(provider.query({})).resolves.toEqual([]); + expect(channel.posted).toHaveLength(postedBefore); + }); + }); + + it('should use the configured channel name', () => { + TestBed.configureTestingModule({ + providers: [ + CpsBroadcastLogApiProvider, + { provide: CPS_BROADCAST_CHANNEL, useValue: 'my-channel' } + ] + }); + TestBed.inject(CpsBroadcastLogApiProvider); + + expect(ChannelStub.instances[0].name).toBe('my-channel'); + }); + + it('should run the timeout outside the Angular zone and resolve inside it', async () => { + create(); + const zone = TestBed.inject(NgZone); + const runOutsideAngular = jest.spyOn(zone, 'runOutsideAngular'); + const run = jest.spyOn(zone, 'run'); + + const pending = provider.query({}); + expect(runOutsideAngular).toHaveBeenCalledTimes(1); + expect(run).not.toHaveBeenCalled(); + + channel.deliver({ + kind: 'log-query-result', + id: lastQueryId(), + records: [] + }); + + await expect(pending).resolves.toEqual([]); + + expect(run).toHaveBeenCalled(); + }); + + describe('without BroadcastChannel', () => { + beforeEach(() => { + ChannelStub.uninstall(); + TestBed.configureTestingModule({ + providers: [CpsBroadcastLogApiProvider] + }); + provider = TestBed.inject(CpsBroadcastLogApiProvider); + }); + + it('should resolve a query to [] at once', async () => { + await expect(provider.query({})).resolves.toEqual([]); + }); + + it('should accept send and flush without throwing', () => { + expect(() => { + provider.send(record); + provider.flush(); + provider.ngOnDestroy(); + }).not.toThrow(); + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/providers/cps-broadcast-log-api.provider/cps-broadcast-log-api.provider.ts b/projects/cps-telemetry/src/lib/providers/cps-broadcast-log-api.provider/cps-broadcast-log-api.provider.ts new file mode 100644 index 000000000..62807cabf --- /dev/null +++ b/projects/cps-telemetry/src/lib/providers/cps-broadcast-log-api.provider/cps-broadcast-log-api.provider.ts @@ -0,0 +1,128 @@ +import { inject, Injectable, NgZone, OnDestroy } from '@angular/core'; +import { CpsLogRecord } from '../../models/cps-log.models/cps-log.models'; +import { + CpsLogApiProvider, + CpsLogQuery +} from '../cps-log-api.provider/cps-log-api.provider'; +import { + cpsSafeVoid, + cpsUuid +} from '../../utils/cps-telemetry-safe.util/cps-telemetry-safe.util'; +import { + CpsBroadcastConnection, + cpsConnectBroadcastChannel, + cpsIsBroadcastLogRecord, + cpsIsBroadcastMessage +} from '../../sinks/cps-broadcast/cps-broadcast.messages'; + +/** + * How long a follower waits for the host to answer a log query before + * resolving to `[]`, in milliseconds. + */ +export const CPS_BROADCAST_LOG_QUERY_TIMEOUT_MS = 10_000; + +/** A log query waiting for the host's answer. */ +interface CpsPendingLogQuery { + resolve: (records: CpsLogRecord[]) => void; + timer: ReturnType<typeof setTimeout>; +} + +/** + * Log API provider for a follower realm — the log counterpart of + * {@link CpsBroadcastTelemetrySink}. + * + * Ships nothing itself. Records are forwarded over the `BroadcastChannel` + * to the realm running {@link provideCpsTelemetryBroadcastHost}, which hands + * them to its own {@link CPS_LOG_API_PROVIDER}: one log backend, one set of + * credentials, for the whole composed page. Bound by + * `provideCpsTelemetrySink('broadcast')`. + * + * `query()` is answered by the host's provider too. It resolves to `[]` + * when no host answers within {@link CPS_BROADCAST_LOG_QUERY_TIMEOUT_MS}, + * and at once when no channel could be opened. The timeout runs outside + * Angular's zone, so a query no host answers doesn't hold the application + * unstable for its whole length. + * + * @group Services + */ +@Injectable() +export class CpsBroadcastLogApiProvider + implements CpsLogApiProvider, OnDestroy +{ + private readonly connection: CpsBroadcastConnection = + cpsConnectBroadcastChannel('broadcastLogApi'); + + private readonly pending = new Map<string, CpsPendingLogQuery>(); + + /** Absent outside an application, e.g. a bare `Injector.create`. */ + private readonly zone = inject(NgZone, { optional: true }); + + constructor() { + this.connection.onMessage((data) => this.onMessage(data)); + } + + /** @inheritdoc */ + send(record: CpsLogRecord): void { + this.connection.post({ kind: 'log', record }); + } + + /** @inheritdoc */ + query(filter: CpsLogQuery): Promise<CpsLogRecord[]> { + if (!this.connection.isOpen()) { + return Promise.resolve([]); + } + + return new Promise((resolve) => { + const id = cpsUuid(); + const timer = this.outsideZone(() => + setTimeout( + () => this.answer(id, []), + CPS_BROADCAST_LOG_QUERY_TIMEOUT_MS + ) + ); + this.pending.set(id, { resolve, timer }); + this.connection.post({ kind: 'log-query', id, filter }); + }); + } + + /** @inheritdoc */ + flush(): void { + this.connection.post({ kind: 'log-flush' }); + } + + /** Resolves every unanswered query to `[]`, then closes the channel. */ + ngOnDestroy(): void { + for (const id of [...this.pending.keys()]) { + this.answer(id, []); + } + this.connection.close(); + } + + private onMessage(data: unknown): void { + cpsSafeVoid('broadcastLogApi.receive', () => { + if (!cpsIsBroadcastMessage(data) || data.kind !== 'log-query-result') { + return; + } + // Other followers' answers arrive here too; answer() ignores them. + this.answer(data.id, data.records.filter(cpsIsBroadcastLogRecord)); + }); + } + + private answer(id: string, records: CpsLogRecord[]): void { + const query = this.pending.get(id); + if (!query) { + return; + } + this.pending.delete(id); + clearTimeout(query.timer); + if (this.zone) { + this.zone.run(() => query.resolve(records)); + } else { + query.resolve(records); + } + } + + private outsideZone<T>(fn: () => T): T { + return this.zone ? this.zone.runOutsideAngular(fn) : fn(); + } +} diff --git a/projects/cps-telemetry/src/lib/providers/cps-log-api.provider/cps-log-api.provider.ts b/projects/cps-telemetry/src/lib/providers/cps-log-api.provider/cps-log-api.provider.ts new file mode 100644 index 000000000..7e840ce3e --- /dev/null +++ b/projects/cps-telemetry/src/lib/providers/cps-log-api.provider/cps-log-api.provider.ts @@ -0,0 +1,105 @@ +import { InjectionToken } from '@angular/core'; +import { + CpsLoggerName, + CpsLogLevel, + CpsLogRecord +} from '../../models/cps-log.models/cps-log.models'; + +/** + * Filter accepted by {@link CpsLoggerService.query}. + * + * Every field is optional and they combine with AND. A provider translates + * them however its store requires. + * + * @group Interfaces + */ +export interface CpsLogQuery { + /** + * Pulls one journey's lines together. Pass a {@link CpsScenario.id} to read + * everything logged during that scenario, across services. + */ + correlationId?: string; + + /** Restricts to one named logger. */ + logger?: CpsLoggerName; + + /** Discards records below this severity. */ + minLevel?: CpsLogLevel; + + /** ISO-8601 lower bound on `timestamp`, inclusive. */ + from?: string; + + /** ISO-8601 upper bound on `timestamp`, inclusive. */ + to?: string; + + /** Maximum number of records to return. */ + limit?: number; +} + +/** + * The application's log store, as this library needs to see it. + * + * Where records are kept, how they are authorised and how long they are + * retained are decisions for the consuming application; this is the seam it + * supplies them through. + * + * @example + * ```typescript + * send(record: CpsLogRecord): void { + * this.http.post('/api/logs', record).subscribe({ error: () => undefined }); + * } + * + * query(filter: CpsLogQuery): Promise<CpsLogRecord[]> { + * return firstValueFrom(this.http.get<CpsLogRecord[]>('/api/logs', { params: { ...filter } })); + * } + * ``` + * + * @group Interfaces + */ +export interface CpsLogApiProvider { + /** + * Ships one record. + * + * Called once per record, as it is written. Must be non-blocking and must + * not throw. A provider that batches accumulates its own queue here and + * flushes it via {@link flush}. + * + * @param record the redacted record + */ + send(record: CpsLogRecord): void; + + /** + * Reads records back. + * + * @param filter narrows what is returned; an empty filter means "everything + * this provider is willing to return" + * @returns the matching records + */ + query(filter: CpsLogQuery): Promise<CpsLogRecord[]>; + + /** + * Ships whatever this provider is holding, if it holds anything. + * + * Optional; only needed by a provider that batches in {@link send} instead + * of sending immediately. + * + * Called on `pagehide`, on `visibilitychange` going hidden, and on + * teardown. Must be synchronous and non-blocking. `fetch` with `keepalive` + * or `navigator.sendBeacon` are the usual choices. + */ + flush?(): void; +} + +/** + * Binds the application's {@link CpsLogApiProvider}. + * + * Bound by the application; `provideCpsTelemetry` wires everything else. + * Has no default — an unbound provider fails at injection rather than + * silently discarding logs. To keep no logs, say so with + * {@link CpsNoopLogApiProvider}. + * + * @group Tokens + */ +export const CPS_LOG_API_PROVIDER = new InjectionToken<CpsLogApiProvider>( + 'CPS_LOG_API_PROVIDER' +); diff --git a/projects/cps-telemetry/src/lib/providers/cps-noop-log-api.provider/cps-noop-log-api.provider.spec.ts b/projects/cps-telemetry/src/lib/providers/cps-noop-log-api.provider/cps-noop-log-api.provider.spec.ts new file mode 100644 index 000000000..d3810a60c --- /dev/null +++ b/projects/cps-telemetry/src/lib/providers/cps-noop-log-api.provider/cps-noop-log-api.provider.spec.ts @@ -0,0 +1,26 @@ +import { CpsNoopLogApiProvider } from './cps-noop-log-api.provider'; + +describe('CpsNoopLogApiProvider', () => { + let provider: CpsNoopLogApiProvider; + + beforeEach(() => { + provider = new CpsNoopLogApiProvider(); + }); + + it('should discard send() without throwing', () => { + expect(() => + provider.send({ + timestamp: '2026-01-01T00:00:00.000Z', + level: 'warn', + message: 'Careful', + application: 'shell', + environment: 'test', + version: '1.0.0' + }) + ).not.toThrow(); + }); + + it('should find no records', async () => { + await expect(provider.query({ correlationId: 'c-1' })).resolves.toEqual([]); + }); +}); diff --git a/projects/cps-telemetry/src/lib/providers/cps-noop-log-api.provider/cps-noop-log-api.provider.ts b/projects/cps-telemetry/src/lib/providers/cps-noop-log-api.provider/cps-noop-log-api.provider.ts new file mode 100644 index 000000000..635097ce5 --- /dev/null +++ b/projects/cps-telemetry/src/lib/providers/cps-noop-log-api.provider/cps-noop-log-api.provider.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@angular/core'; +import { CpsLogRecord } from '../../models/cps-log.models/cps-log.models'; +import { + CpsLogApiProvider, + CpsLogQuery +} from '../cps-log-api.provider/cps-log-api.provider'; + +/** + * Log API provider that discards every record and finds none. + * + * The explicit way to say an application has no log backend — never a + * default, like `provideCpsTelemetrySink('noop')`. Mostly for a shell that + * hosts fragments with {@link provideCpsTelemetryBroadcastHost} but logs + * nothing: the host requires a log provider, so that one can't be + * forgotten. + * + * Bound in a shell, it discards its fragments' log records too. + * + * @example + * ```typescript + * providers: [ + * { provide: CPS_LOG_API_PROVIDER, useClass: CpsNoopLogApiProvider }, + * provideCpsTelemetryBroadcastHost() + * ] + * ``` + * + * @group Services + */ +@Injectable() +export class CpsNoopLogApiProvider implements CpsLogApiProvider { + /** @inheritdoc */ + send(_record: CpsLogRecord): void {} + + /** @inheritdoc */ + query(_filter: CpsLogQuery): Promise<CpsLogRecord[]> { + return Promise.resolve([]); + } +} diff --git a/projects/cps-telemetry/src/lib/providers/cps-telemetry-common.providers/cps-telemetry-common.providers.spec.ts b/projects/cps-telemetry/src/lib/providers/cps-telemetry-common.providers/cps-telemetry-common.providers.spec.ts new file mode 100644 index 000000000..9e1a9afb0 --- /dev/null +++ b/projects/cps-telemetry/src/lib/providers/cps-telemetry-common.providers/cps-telemetry-common.providers.spec.ts @@ -0,0 +1,555 @@ +import { CpsLogRecord } from '../../models/cps-log.models/cps-log.models'; +import { ApplicationInitStatus, Injectable, PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CpsLoggerService } from '../../services/cps-logger.service/cps-logger.service'; +import { CpsScenarioTelemetryService } from '../../services/cps-scenario-telemetry.service/cps-scenario-telemetry.service'; +import { CpsNoopTelemetrySink } from '../../sinks/cps-telemetry/cps-noop-telemetry.sink/cps-noop-telemetry.sink'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { + CPS_LOG_API_PROVIDER, + CpsLogApiProvider, + CpsLogQuery +} from '../cps-log-api.provider/cps-log-api.provider'; +import { + CPS_DEFAULT_TELEMETRY_CONFIG, + CPS_REDACT_CONFIG, + CPS_TELEMETRY_IDENTITY +} from '../../config/cps-telemetry-common.config/cps-telemetry-common.config'; +import { CPS_BI_TELEMETRY_CONFIG } from '../../config/cps-bi-telemetry.config/cps-bi-telemetry.config'; +import { CPS_LOG_CONFIG } from '../../config/cps-log.config/cps-log.config'; +import { CPS_SCENARIO_TELEMETRY_CONFIG } from '../../config/cps-scenario-telemetry.config/cps-scenario-telemetry.config'; +import { CpsBroadcastTelemetrySink } from '../../sinks/cps-broadcast/cps-broadcast-telemetry.sink'; +import { CpsBroadcastLogApiProvider } from '../cps-broadcast-log-api.provider/cps-broadcast-log-api.provider'; +import { CpsNoopLogApiProvider } from '../cps-noop-log-api.provider/cps-noop-log-api.provider'; +import { CPS_BROADCAST_CHANNEL } from '../../sinks/cps-broadcast/cps-broadcast.messages'; +import { + CpsTelemetryFeature, + CpsTelemetryLocalSinkMode, + provideCpsTelemetry, + provideCpsTelemetryBroadcastHost, + provideCpsTelemetrySink, + withBiEvents, + withLogging, + withRedaction, + withScenarios +} from './cps-telemetry-common.providers'; + +/** Keeps every record, so a test can assert on what was shipped. */ +@Injectable() +class RecordingLogApi implements CpsLogApiProvider { + readonly records: CpsLogRecord[] = []; + + send(record: CpsLogRecord): void { + this.records.push(record); + } + + query(filter: CpsLogQuery): Promise<CpsLogRecord[]> { + let found = this.records; + if (filter.correlationId) { + found = found.filter((r) => r.correlationId === filter.correlationId); + } + if (filter.logger) { + found = found.filter((r) => r.logger === filter.logger); + } + if (filter.limit !== undefined) { + found = found.slice(0, filter.limit); + } + return Promise.resolve(found); + } +} + +/** + * Minimal `BroadcastChannel` stand-in for observing what one connection + * posts. See `cps-broadcast.spec.ts` for the full cross-realm stub. + */ +class RecordingChannelStub { + static posted: unknown[] = []; + + onmessage: ((event: { data: unknown }) => void) | null = null; + readonly name: string; + + constructor(name: string) { + this.name = name; + } + + postMessage(message: unknown): void { + RecordingChannelStub.posted.push(message); + } + + close(): void {} + + static install(): void { + RecordingChannelStub.posted = []; + Object.defineProperty(globalThis, 'BroadcastChannel', { + value: RecordingChannelStub, + configurable: true, + writable: true + }); + } + + static uninstall(): void { + delete (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel; + } +} + +describe('provideCpsTelemetry', () => { + /** Configuration only — a destination has to be chosen separately. */ + function configureAlone(): void { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'my-app', environment: 'prod', version: '1.0.0' }, + withLogging({ minLevel: 'warn' }) + ) + ] + }); + } + + it('should provide the application identity', () => { + configureAlone(); + expect(TestBed.inject(CPS_TELEMETRY_IDENTITY)).toMatchObject({ + application: 'my-app', + environment: 'prod', + version: '1.0.0' + }); + }); + + it('should apply a with*() override over the library default', () => { + configureAlone(); + expect(TestBed.inject(CPS_LOG_CONFIG)).toMatchObject({ minLevel: 'warn' }); + }); + + it('should default every concern not given a with*() feature', () => { + configureAlone(); + expect(TestBed.inject(CPS_SCENARIO_TELEMETRY_CONFIG)).toEqual( + CPS_DEFAULT_TELEMETRY_CONFIG.scenario + ); + expect(TestBed.inject(CPS_BI_TELEMETRY_CONFIG)).toEqual( + CPS_DEFAULT_TELEMETRY_CONFIG.bi + ); + expect(TestBed.inject(CPS_REDACT_CONFIG)).toEqual( + CPS_DEFAULT_TELEMETRY_CONFIG.redact + ); + }); + + it('should provide no sink of its own', () => { + configureAlone(); + expect(() => TestBed.inject(CpsTelemetrySink)).toThrow(); + }); + + it('should provide no log destination of its own', () => { + configureAlone(); + expect(() => TestBed.inject(CpsLoggerService)).toThrow( + /CPS_LOG_API_PROVIDER/ + ); + }); + + it('should fail loudly when a service is injected with no destination', () => { + configureAlone(); + expect(() => TestBed.inject(CpsScenarioTelemetryService)).toThrow(); + }); + + it('should work once a destination is chosen', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'my-app', + environment: 'prod', + version: '1.0.0' + }), + provideCpsTelemetrySink('noop'), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi } + ] + }); + + const scenario = TestBed.inject(CpsScenarioTelemetryService).start({ + name: 'load' + }); + + expect(() => scenario.step('one').complete()).not.toThrow(); + expect(scenario.status).toBe('success'); + expect(TestBed.inject(CpsLoggerService)).toBeTruthy(); + }); +}); + +describe('provideCpsTelemetrySink', () => { + function configure( + mode: CpsTelemetryLocalSinkMode, + options?: { channelName?: string } + ): void { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'cart', + environment: 'prod', + version: '1.0.0' + }), + provideCpsTelemetrySink(mode, options), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi } + ] + }); + } + + it('should wire the forwarding sink for an embedded deployment', () => { + configure('broadcast'); + expect(TestBed.inject(CpsTelemetrySink)).toBeInstanceOf( + CpsBroadcastTelemetrySink + ); + }); + + it('should send log records to the shell in broadcast mode, needing no log provider of its own', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'cart', + environment: 'prod', + version: '1.0.0' + }), + provideCpsTelemetrySink('broadcast') + ] + }); + + expect(TestBed.inject(CPS_LOG_API_PROVIDER)).toBeInstanceOf( + CpsBroadcastLogApiProvider + ); + expect(TestBed.inject(CpsLoggerService)).toBeTruthy(); + }); + + it('should wire a discarding sink when telemetry is switched off', () => { + configure('noop'); + expect(TestBed.inject(CpsTelemetrySink)).toBeInstanceOf( + CpsNoopTelemetrySink + ); + }); + + it('should pass the channel name through in broadcast mode', () => { + configure('broadcast', { channelName: 'my-channel' }); + expect(TestBed.inject(CPS_BROADCAST_CHANNEL)).toBe('my-channel'); + }); + + it('should leave the channel name unbound when none is given', () => { + configure('broadcast'); + expect( + TestBed.inject(CPS_BROADCAST_CHANNEL, null, { optional: true }) + ).toBeNull(); + }); + + it.each(['broadcast', 'noop'] as CpsTelemetryLocalSinkMode[])( + 'should leave application code unchanged in %s mode', + (mode) => { + configure(mode); + const scenario = TestBed.inject(CpsScenarioTelemetryService).start({ + name: 'add-to-cart' + }); + + expect(() => scenario.step('one').complete()).not.toThrow(); + expect(scenario.status).toBe('success'); + } + ); +}); + +describe('provideCpsTelemetryBroadcastHost', () => { + afterEach(() => { + RecordingChannelStub.uninstall(); + }); + + it('should construct the host eagerly via app initialization, with nothing else injecting it', async () => { + RecordingChannelStub.install(); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + provideCpsTelemetry({ + application: 'shell', + environment: 'prod', + version: '1.0.0' + }), + provideCpsTelemetrySink('noop'), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + provideCpsTelemetryBroadcastHost() + ] + }); + + await TestBed.inject(ApplicationInitStatus).donePromise; + + expect(RecordingChannelStub.posted).toContainEqual( + expect.objectContaining({ kind: 'identity' }) + ); + }); + + it('should fail at bootstrap when the shell binds no log provider', () => { + RecordingChannelStub.install(); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + provideCpsTelemetry({ + application: 'shell', + environment: 'prod', + version: '1.0.0' + }), + provideCpsTelemetrySink('noop'), + provideCpsTelemetryBroadcastHost() + ] + }); + expect(() => TestBed.inject(ApplicationInitStatus)).toThrow( + /CPS_LOG_API_PROVIDER/ + ); + }); + + it('should bootstrap a shell that states it has no log backend', async () => { + RecordingChannelStub.install(); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + provideCpsTelemetry({ + application: 'shell', + environment: 'prod', + version: '1.0.0' + }), + provideCpsTelemetrySink('noop'), + { provide: CPS_LOG_API_PROVIDER, useClass: CpsNoopLogApiProvider }, + provideCpsTelemetryBroadcastHost() + ] + }); + + await TestBed.inject(ApplicationInitStatus).donePromise; + + expect(RecordingChannelStub.posted).toContainEqual( + expect.objectContaining({ kind: 'identity' }) + ); + }); + + it('should pass the channel name through', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + provideCpsTelemetry({ + application: 'shell', + environment: 'prod', + version: '1.0.0' + }), + provideCpsTelemetrySink('noop'), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + provideCpsTelemetryBroadcastHost('my-channel') + ] + }); + + expect(TestBed.inject(CPS_BROADCAST_CHANNEL)).toBe('my-channel'); + }); + + it('should leave the channel name unbound when none is given', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + provideCpsTelemetry({ + application: 'shell', + environment: 'prod', + version: '1.0.0' + }), + provideCpsTelemetrySink('noop'), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + provideCpsTelemetryBroadcastHost() + ] + }); + + expect( + TestBed.inject(CPS_BROADCAST_CHANNEL, null, { optional: true }) + ).toBeNull(); + }); +}); + +describe('custom implementations', () => { + it('should reach the application log API through one binding alone', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'my-app', + environment: 'prod', + version: '1.0.0' + }), + provideCpsTelemetrySink('noop'), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi } + ] + }); + + const api = TestBed.inject(RecordingLogApi); + TestBed.inject(CpsLoggerService).getLogger('test').log('hello'); + + expect(api.records).toHaveLength(1); + expect(api.records[0].message).toBe('hello'); + }); +}); + +describe('with*() features', () => { + const identity = { + application: 'my-app', + environment: 'prod', + version: '1.0.0' + }; + + function configure(...features: CpsTelemetryFeature[]): void { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideCpsTelemetry(identity, ...features)] + }); + } + + it('should default the event namespace to com.cps', () => { + configure(); + expect(TestBed.inject(CPS_TELEMETRY_IDENTITY).eventNamespace).toBe( + 'com.cps' + ); + }); + + it('should accept an application-specific event namespace', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ ...identity, eventNamespace: 'com.test-app' }) + ] + }); + expect(TestBed.inject(CPS_TELEMETRY_IDENTITY).eventNamespace).toBe( + 'com.test-app' + ); + }); + + it('should merge withLogging over the library default rather than replacing it wholesale', () => { + configure(withLogging({ minLevel: 'warn' })); + const logs = TestBed.inject(CPS_LOG_CONFIG); + expect(logs.minLevel).toBe('warn'); + expect(logs.mirrorErrorsToRum).toBe( + CPS_DEFAULT_TELEMETRY_CONFIG.logs.mirrorErrorsToRum + ); + }); + + it('should merge withScenarios over the library default', () => { + configure(withScenarios({ maxSteps: 10 })); + const scenario = TestBed.inject(CPS_SCENARIO_TELEMETRY_CONFIG); + expect(scenario.maxSteps).toBe(10); + expect(scenario.defaultTimeoutMs).toBe( + CPS_DEFAULT_TELEMETRY_CONFIG.scenario.defaultTimeoutMs + ); + }); + + it('should merge withBiEvents over the library default', () => { + configure(withBiEvents({ dedupWindowMs: 1_000 })); + const bi = TestBed.inject(CPS_BI_TELEMETRY_CONFIG); + expect(bi.dedupWindowMs).toBe(1_000); + expect(bi.dedupMaxKeys).toBe(CPS_DEFAULT_TELEMETRY_CONFIG.bi.dedupMaxKeys); + }); + + it('should merge withRedaction over the library default', () => { + configure(withRedaction({ includeStack: false })); + const redact = TestBed.inject(CPS_REDACT_CONFIG); + expect(redact.includeStack).toBe(false); + expect(redact.maxStringLength).toBe( + CPS_DEFAULT_TELEMETRY_CONFIG.redact.maxStringLength + ); + }); + + describe('withRedaction array identity', () => { + it('should give each call its own extraKeyPatterns array', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideCpsTelemetry(identity, withRedaction())] + }); + const redactA = TestBed.inject(CPS_REDACT_CONFIG); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideCpsTelemetry(identity, withRedaction())] + }); + const redactB = TestBed.inject(CPS_REDACT_CONFIG); + + expect(redactA.extraKeyPatterns).not.toBe(redactB.extraKeyPatterns); + expect(redactA.extraKeyPatterns).not.toBe( + CPS_DEFAULT_TELEMETRY_CONFIG.redact.extraKeyPatterns + ); + }); + + it('should not leak a mutation of one resolved extraKeyPatterns into another', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideCpsTelemetry(identity, withRedaction())] + }); + TestBed.inject(CPS_REDACT_CONFIG).extraKeyPatterns.push(/leaked/i); + + expect(CPS_DEFAULT_TELEMETRY_CONFIG.redact.extraKeyPatterns).toEqual([]); + }); + + it('should still copy an explicitly supplied array, not share it back', () => { + const shared = [/customerRef/i]; + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + identity, + withRedaction({ extraKeyPatterns: shared }) + ) + ] + }); + + TestBed.inject(CPS_REDACT_CONFIG).extraKeyPatterns.push(/addedLater/i); + + expect(shared).toHaveLength(1); + }); + + it('should give each call its own extraValueTransforms array too', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideCpsTelemetry(identity, withRedaction())] + }); + const redactA = TestBed.inject(CPS_REDACT_CONFIG); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [provideCpsTelemetry(identity, withRedaction())] + }); + const redactB = TestBed.inject(CPS_REDACT_CONFIG); + + expect(redactA.extraValueTransforms).not.toBe( + redactB.extraValueTransforms + ); + expect(redactA.extraValueTransforms).not.toBe( + CPS_DEFAULT_TELEMETRY_CONFIG.redact.extraValueTransforms + ); + }); + + it('should still copy an explicitly supplied extraValueTransforms array, not share it back', () => { + const shared = [(value: string) => value]; + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + identity, + withRedaction({ extraValueTransforms: shared }) + ) + ] + }); + + TestBed.inject(CPS_REDACT_CONFIG).extraValueTransforms.push( + (value) => value + ); + + expect(shared).toHaveLength(1); + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/providers/cps-telemetry-common.providers/cps-telemetry-common.providers.ts b/projects/cps-telemetry/src/lib/providers/cps-telemetry-common.providers/cps-telemetry-common.providers.ts new file mode 100644 index 000000000..7d1ad72f3 --- /dev/null +++ b/projects/cps-telemetry/src/lib/providers/cps-telemetry-common.providers/cps-telemetry-common.providers.ts @@ -0,0 +1,305 @@ +import { + EnvironmentProviders, + inject, + makeEnvironmentProviders, + Provider, + provideAppInitializer +} from '@angular/core'; +import { CpsTelemetryBroadcastHost } from '../../sinks/cps-broadcast/cps-broadcast-host.service'; +import { CpsBroadcastTelemetrySink } from '../../sinks/cps-broadcast/cps-broadcast-telemetry.sink'; +import { CpsBroadcastLogApiProvider } from '../cps-broadcast-log-api.provider/cps-broadcast-log-api.provider'; +import { CPS_BROADCAST_CHANNEL } from '../../sinks/cps-broadcast/cps-broadcast.messages'; +import { CpsNoopTelemetrySink } from '../../sinks/cps-telemetry/cps-noop-telemetry.sink/cps-noop-telemetry.sink'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { CpsRedactConfig } from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; +import { CPS_LOG_API_PROVIDER } from '../cps-log-api.provider/cps-log-api.provider'; +import { + CPS_REDACT_CONFIG, + CPS_TELEMETRY_IDENTITY, + CPS_DEFAULT_TELEMETRY_CONFIG, + CpsTelemetryIdentity +} from '../../config/cps-telemetry-common.config/cps-telemetry-common.config'; +import { + CPS_BI_TELEMETRY_CONFIG, + CpsBiTelemetryConfig +} from '../../config/cps-bi-telemetry.config/cps-bi-telemetry.config'; +import { + CPS_LOG_CONFIG, + CpsLogConfig +} from '../../config/cps-log.config/cps-log.config'; +import { + CPS_SCENARIO_TELEMETRY_CONFIG, + CpsScenarioTelemetryConfig +} from '../../config/cps-scenario-telemetry.config/cps-scenario-telemetry.config'; + +/** + * One optional concern's providers, composed onto {@link provideCpsTelemetry}. + * + * Applications never construct one directly — only through `withLogging`, + * `withScenarios`, `withBiEvents` or `withRedaction`. + * + * @group Types + */ +export interface CpsTelemetryFeature { + providers: Provider[]; +} + +/** + * Configures logging. Omit to take the library defaults. + * + * @param config overrides merged over the library defaults + * @returns a feature for {@link provideCpsTelemetry} + * + * @group Utils + */ +export function withLogging( + config: Partial<CpsLogConfig> = {} +): CpsTelemetryFeature { + return { + providers: [ + { + provide: CPS_LOG_CONFIG, + useValue: { ...CPS_DEFAULT_TELEMETRY_CONFIG.logs, ...config } + } + ] + }; +} + +/** + * Configures scenario telemetry. Omit to take the library defaults. + * + * @param config overrides merged over the library defaults + * @returns a feature for {@link provideCpsTelemetry} + * + * @group Utils + */ +export function withScenarios( + config: Partial<CpsScenarioTelemetryConfig> = {} +): CpsTelemetryFeature { + return { + providers: [ + { + provide: CPS_SCENARIO_TELEMETRY_CONFIG, + useValue: { ...CPS_DEFAULT_TELEMETRY_CONFIG.scenario, ...config } + } + ] + }; +} + +/** + * Configures BI event tracking. Omit to take the library defaults. + * + * @param config overrides merged over the library defaults + * @returns a feature for {@link provideCpsTelemetry} + * + * @group Utils + */ +export function withBiEvents( + config: Partial<CpsBiTelemetryConfig> = {} +): CpsTelemetryFeature { + return { + providers: [ + { + provide: CPS_BI_TELEMETRY_CONFIG, + useValue: { ...CPS_DEFAULT_TELEMETRY_CONFIG.bi, ...config } + } + ] + }; +} + +/** + * Configures PII redaction, shared by every concern. Omit to take the library + * defaults. + * + * `extraKeyPatterns`/`extraValuePatterns` are copied into a fresh array, so + * callers never share array identity with the defaults or each other. + * + * @param config overrides merged over the library defaults + * @returns a feature for {@link provideCpsTelemetry} + * + * @group Utils + */ +export function withRedaction( + config: Partial<CpsRedactConfig> = {} +): CpsTelemetryFeature { + const defaults = CPS_DEFAULT_TELEMETRY_CONFIG.redact; + return { + providers: [ + { + provide: CPS_REDACT_CONFIG, + useValue: { + ...defaults, + ...config, + extraKeyPatterns: [ + ...(config.extraKeyPatterns ?? defaults.extraKeyPatterns) + ], + extraValuePatterns: [ + ...(config.extraValuePatterns ?? defaults.extraValuePatterns) + ], + extraValueTransforms: [ + ...(config.extraValueTransforms ?? defaults.extraValueTransforms) + ] + } + } + ] + }; +} + +/** + * Registers the telemetry layer's configuration. + * + * Identity — `application`/`environment`/`version` — is mandatory and stated + * once; every other concern is an optional, individually named feature + * (`withLogging`, `withScenarios`, `withBiEvents`, `withRedaction`). Each + * feature's token (e.g. {@link CPS_LOG_CONFIG}) can also be overridden + * directly via plain DI substitution. + * + * Provides no destination. A sink and a log API provider must be bound + * separately; injecting a telemetry service without them fails at bootstrap + * with `NG0201`. + * + * @example + * ```typescript + * import { provideCpsTelemetryRumSink, CPS_RUM_CREDENTIALS_PROVIDER } from 'cps-telemetry/rum'; + * + * providers: [ + * provideCpsTelemetry( + * { application: 'composition', environment: 'prod', version: '22.0.0' }, + * withLogging({ minLevel: 'warn' }), + * withScenarios({ maxSteps: 10 }) + * ), + * provideCpsTelemetryRumSink(), + * { provide: CPS_LOG_API_PROVIDER, useExisting: MyLogApiProvider }, + * + * { provide: CPS_RUM_CREDENTIALS_PROVIDER, useExisting: AppRumCredentials } + * ] + * ``` + * + * @param identity the application's identity, shared by every concern + * @param features optional per-concern configuration; omitted ones take the + * library defaults + * @returns providers for the telemetry configuration + * + * @group Utils + */ +export function provideCpsTelemetry( + identity: CpsTelemetryIdentity, + ...features: CpsTelemetryFeature[] +): EnvironmentProviders { + return makeEnvironmentProviders([ + { + provide: CPS_TELEMETRY_IDENTITY, + useValue: { + application: identity.application, + environment: identity.environment, + version: identity.version, + eventNamespace: + identity.eventNamespace || CPS_DEFAULT_TELEMETRY_CONFIG.eventNamespace + } + }, + ...withLogging().providers, + ...withScenarios().providers, + ...withBiEvents().providers, + ...withRedaction().providers, + ...features.flatMap((f) => f.providers) + ]); +} + +/** + * Where a realm sends its telemetry, selectable via + * {@link provideCpsTelemetrySink} without any optional peer dependency. + * AWS RUM isn't one of these — it lives in its own entry point; see + * {@link provideCpsTelemetryRumSink}. + * + * @group Types + */ +export type CpsTelemetryLocalSinkMode = + /** To a shell realm running {@link provideCpsTelemetryBroadcastHost}. */ + | 'broadcast' + /** Nowhere. Everything still runs; nothing is shipped. */ + | 'noop'; + +/** + * Binds the telemetry destination. Every application needs exactly one call + * — this one, or {@link provideCpsTelemetryRumSink} for the RUM sink. + * + * - `'broadcast'` forwards to a shell realm running + * {@link provideCpsTelemetryBroadcastHost}, on a channel both sides name + * identically. Log records go there too: this mode also binds + * {@link CPS_LOG_API_PROVIDER} to {@link CpsBroadcastLogApiProvider}, so + * the shell's log provider ships them and answers `query()`. + * - `'noop'` discards everything. + * + * Sending straight to AWS CloudWatch RUM is `provideCpsTelemetryRumSink()`, + * imported from `cps-telemetry/rum` — a separate entry point, not a third + * mode here, so that an application using only `'broadcast'`/`'noop'` is + * never required to have the optional `aws-rum-web` peer resolvable at + * build time. See DESIGN.md §3, "Entry points". + * + * @example + * ```typescript + * providers: [ + * provideCpsTelemetry({ application: 'cart', environment, version }), + * provideCpsTelemetrySink(environment.embedded ? 'broadcast' : 'noop') + * ] + * ``` + * + * @param mode where this realm should send telemetry + * @param options `channelName` for `broadcast` mode; must match the host's + * @returns providers wiring the chosen sink + * + * @group Utils + */ +export function provideCpsTelemetrySink( + mode: CpsTelemetryLocalSinkMode, + options?: { channelName?: string } +): EnvironmentProviders { + switch (mode) { + case 'broadcast': + return makeEnvironmentProviders([ + CpsBroadcastTelemetrySink, + { provide: CpsTelemetrySink, useExisting: CpsBroadcastTelemetrySink }, + CpsBroadcastLogApiProvider, + { + provide: CPS_LOG_API_PROVIDER, + useExisting: CpsBroadcastLogApiProvider + }, + ...(options?.channelName + ? [{ provide: CPS_BROADCAST_CHANNEL, useValue: options.channelName }] + : []) + ]); + + case 'noop': + return makeEnvironmentProviders([ + { provide: CpsTelemetrySink, useClass: CpsNoopTelemetrySink } + ]); + + default: + throw new Error(`[cps-telemetry] Unknown sink mode "${mode}".`); + } +} + +/** + * Records telemetry forwarded by follower realms through this realm's sink. + * + * Provide it in the shell, alongside the real sink. Exactly one realm should. + * + * @param channelName the `BroadcastChannel` name; must match the followers' + * @returns providers wiring the broadcast host + * + * @group Utils + */ +export function provideCpsTelemetryBroadcastHost( + channelName?: string +): EnvironmentProviders { + return makeEnvironmentProviders([ + CpsTelemetryBroadcastHost, + ...(channelName + ? [{ provide: CPS_BROADCAST_CHANNEL, useValue: channelName }] + : []), + // Constructed eagerly so it is listening before any fragment sends. + provideAppInitializer(() => { + inject(CpsTelemetryBroadcastHost); + }) + ]); +} diff --git a/projects/cps-telemetry/src/lib/scenario/cps-scenario-operators/cps-scenario-operators.spec.ts b/projects/cps-telemetry/src/lib/scenario/cps-scenario-operators/cps-scenario-operators.spec.ts new file mode 100644 index 000000000..9799afa3a --- /dev/null +++ b/projects/cps-telemetry/src/lib/scenario/cps-scenario-operators/cps-scenario-operators.spec.ts @@ -0,0 +1,295 @@ +import { defer, EMPTY, of, Subject, switchMap, throwError } from 'rxjs'; +import { CpsScenario } from '../cps-scenario/cps-scenario'; +import { traceScenario } from './cps-scenario-operators'; + +describe('traceScenario operator', () => { + let scenario: jest.Mocked<CpsScenario>; + + beforeEach(() => { + scenario = { + complete: jest.fn(), + fail: jest.fn(), + cancel: jest.fn(), + isSettled: false + } as unknown as jest.Mocked<CpsScenario>; + }); + + it('should complete scenario on successful stream completion', (done) => { + of(['user1', 'user2']) + .pipe(traceScenario(scenario)) + .subscribe({ + next: (val) => { + expect(val).toEqual(['user1', 'user2']); + }, + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith(undefined); + expect(scenario.fail).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should complete scenario with mapped outcome using function argument', (done) => { + of(['item1', 'item2', 'item3']) + .pipe( + traceScenario(scenario, (items) => ({ + metadata: { itemCount: items.length } + })) + ) + .subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith({ + metadata: { itemCount: 3 } + }); + done(); + } + }); + }); + + it('should complete scenario with mapped outcome using options object', (done) => { + of({ status: 200, data: 'ok' }) + .pipe( + traceScenario(scenario, { + outcome: (res) => ({ + statusCode: res.status, + metadata: { result: res.data } + }) + }) + ) + .subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith({ + statusCode: 200, + metadata: { result: 'ok' } + }); + done(); + } + }); + }); + + it('should complete scenario with no outcome when the stream completes with no emissions', (done) => { + EMPTY.pipe( + traceScenario(scenario, () => ({ metadata: { unreachable: true } })) + ).subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith(undefined); + expect(scenario.fail).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should fail scenario when the stream errors and re-throw the error', (done) => { + const error = new Error('network down'); + throwError(() => error) + .pipe(traceScenario(scenario)) + .subscribe({ + error: (err) => { + expect(err).toBe(error); + expect(scenario.fail).toHaveBeenCalledWith({ error }); + expect(scenario.complete).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should still settle the scenario when the outcome mapper throws', (done) => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + + of('value') + .pipe( + traceScenario(scenario, () => { + throw new Error('mapper is broken'); + }) + ) + .subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenCalledWith(undefined); + expect(scenario.fail).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('failed'), + expect.any(Error) + ); + consoleError.mockRestore(); + done(); + } + }); + }); + + it('should not leak lastValue/hasValue into a resubscription of the same piped observable', (done) => { + let subscriptionCount = 0; + const source = defer(() => { + subscriptionCount++; + return subscriptionCount === 1 ? of('a') : EMPTY; + }); + + const traced$ = source.pipe( + traceScenario(scenario, (value) => ({ metadata: { value } })) + ); + + traced$.subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenNthCalledWith(1, { + metadata: { value: 'a' } + }); + + traced$.subscribe({ + complete: () => { + expect(scenario.complete).toHaveBeenNthCalledWith(2, undefined); + done(); + } + }); + } + }); + }); + + describe('teardown without a source complete/error', () => { + it('should cancel the scenario on a manual unsubscribe', () => { + const source = new Subject<string>(); + const subscription = source.pipe(traceScenario(scenario)).subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledTimes(1); + expect(scenario.complete).not.toHaveBeenCalled(); + expect(scenario.fail).not.toHaveBeenCalled(); + }); + + it('should cancel a superseded scenario when switchMap moves to the next inner observable', () => { + const supersededScenario = { + complete: jest.fn(), + fail: jest.fn(), + cancel: jest.fn(), + isSettled: false + } as unknown as jest.Mocked<CpsScenario>; + const pendingFirstRequest = new Subject<string>(); + const secondRequest = of('b'); + const trigger = new Subject<number>(); + + trigger + .pipe( + switchMap((n) => + (n === 1 ? pendingFirstRequest : secondRequest).pipe( + traceScenario(n === 1 ? supersededScenario : scenario) + ) + ) + ) + .subscribe(); + + trigger.next(1); // subscribes traceScenario(supersededScenario), still pending + trigger.next(2); // switchMap unsubscribes it before it ever settles + + expect(supersededScenario.cancel).toHaveBeenCalledTimes(1); + expect(supersededScenario.complete).not.toHaveBeenCalled(); + expect(supersededScenario.fail).not.toHaveBeenCalled(); + expect(scenario.complete).toHaveBeenCalledWith(undefined); + }); + + it('should cancel with the configured cancelOutcome on unsubscribe', () => { + const source = new Subject<string>(); + const subscription = source + .pipe( + traceScenario(scenario, { cancelOutcome: { reason: 'superseded' } }) + ) + .subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledWith({ reason: 'superseded' }); + }); + + it('should accept cancelOutcome as a function, evaluated at unsubscribe time', () => { + const source = new Subject<string>(); + const subscription = source + .pipe( + traceScenario(scenario, { + cancelOutcome: () => ({ reason: 'superseded' }) + }) + ) + .subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledWith({ reason: 'superseded' }); + }); + + it('should still cancel, with no outcome, when cancelOutcome throws', () => { + const consoleError = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + const source = new Subject<string>(); + const subscription = source + .pipe( + traceScenario(scenario, { + cancelOutcome: () => { + throw new Error('cancelOutcome is broken'); + } + }) + ) + .subscribe(); + + subscription.unsubscribe(); + + expect(scenario.cancel).toHaveBeenCalledWith(undefined); + consoleError.mockRestore(); + }); + + it('should record a reason for a switchMap-superseded scenario, not just a bare cancel', () => { + const supersededScenario = { + complete: jest.fn(), + fail: jest.fn(), + cancel: jest.fn(), + isSettled: false + } as unknown as jest.Mocked<CpsScenario>; + const pendingFirstRequest = new Subject<string>(); + const secondRequest = of('b'); + const trigger = new Subject<number>(); + + trigger + .pipe( + switchMap((n) => + (n === 1 ? pendingFirstRequest : secondRequest).pipe( + traceScenario(n === 1 ? supersededScenario : scenario, { + cancelOutcome: { reason: 'superseded' } + }) + ) + ) + ) + .subscribe(); + + trigger.next(1); + trigger.next(2); + + expect(supersededScenario.cancel).toHaveBeenCalledWith({ + reason: 'superseded' + }); + }); + + it('should not cancel a scenario that already completed normally', (done) => { + of('value') + .pipe(traceScenario(scenario)) + .subscribe({ + complete: () => { + (scenario as unknown as { isSettled: boolean }).isSettled = true; + expect(scenario.cancel).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should not cancel a scenario that already failed', (done) => { + const error = new Error('boom'); + throwError(() => error) + .pipe(traceScenario(scenario)) + .subscribe({ + error: () => { + (scenario as unknown as { isSettled: boolean }).isSettled = true; + expect(scenario.cancel).not.toHaveBeenCalled(); + done(); + } + }); + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/scenario/cps-scenario-operators/cps-scenario-operators.ts b/projects/cps-telemetry/src/lib/scenario/cps-scenario-operators/cps-scenario-operators.ts new file mode 100644 index 000000000..7674f0c90 --- /dev/null +++ b/projects/cps-telemetry/src/lib/scenario/cps-scenario-operators/cps-scenario-operators.ts @@ -0,0 +1,98 @@ +import { defer, MonoTypeOperatorFunction, Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { CpsScenarioOutcome } from '../../models/cps-scenario.models/cps-scenario.models'; +import { CpsScenario } from '../cps-scenario/cps-scenario'; +import { cpsSafe } from '../../utils/cps-telemetry-safe.util/cps-telemetry-safe.util'; + +/** + * Options for configuring scenario completion within an RxJS stream. + * + * @group Interfaces + */ +export interface CpsTraceScenarioOptions<T> { + /** + * Optional mapper that returns an outcome (metadata, statusCode, message) + * based on the emitted value when the Observable completes successfully. + */ + outcome?: (value: T) => CpsScenarioOutcome | void; + + /** + * Outcome used when the stream is unsubscribed before completing or + * erroring — e.g. a `switchMap` moving on to a newer value. + */ + cancelOutcome?: CpsScenarioOutcome | (() => CpsScenarioOutcome | void); +} + +/** + * An RxJS pipeable operator that automatically settles a {@link CpsScenario} + * based on the stream's outcome: `complete()` on completion, `fail({ error })` + * on error. + * + * @example + * ```typescript + * this.http.get<Customer[]>('/api/customers') + * .pipe( + * traceScenario(scenario, { + * outcome: (customers) => ({ metadata: { count: customers.length } }) + * }) + * ) + * .subscribe(); + * ``` + * + * @param scenario the scenario instance to track + * @param options optional outcome mapper or configuration + * + * @group Utils + */ +export function traceScenario<T>( + scenario: CpsScenario, + options?: + CpsTraceScenarioOptions<T> | ((value: T) => CpsScenarioOutcome | void) +): MonoTypeOperatorFunction<T> { + const outcomeMapper = + typeof options === 'function' ? options : options?.outcome; + const cancelOutcome = + typeof options === 'function' ? undefined : options?.cancelOutcome; + + return (source: Observable<T>): Observable<T> => + defer(() => { + let lastValue: T | undefined; + let hasValue = false; + + return source.pipe( + tap({ + next: (value) => { + lastValue = value; + hasValue = true; + }, + complete: () => { + const outcome = + hasValue && outcomeMapper + ? cpsSafe( + 'scenario.traceOutcome', + () => outcomeMapper(lastValue as T), + undefined + ) + : undefined; + scenario.complete(outcome || undefined); + }, + error: (error: unknown) => { + scenario.fail({ error }); + }, + unsubscribe: () => { + if (!scenario.isSettled) { + const resolved = + typeof cancelOutcome === 'function' + ? cpsSafe( + 'scenario.traceCancelOutcome', + cancelOutcome, + undefined + ) + : cancelOutcome; + scenario.cancel(resolved || undefined); + } + } + }) + ); + }); +} diff --git a/projects/cps-telemetry/src/lib/scenario/cps-scenario/cps-scenario.spec.ts b/projects/cps-telemetry/src/lib/scenario/cps-scenario/cps-scenario.spec.ts new file mode 100644 index 000000000..76ad30afa --- /dev/null +++ b/projects/cps-telemetry/src/lib/scenario/cps-scenario/cps-scenario.spec.ts @@ -0,0 +1,354 @@ +import { CPS_DEFAULT_TELEMETRY_CONFIG } from '../../config/cps-telemetry-common.config/cps-telemetry-common.config'; +import { CpsScenarioRecord } from '../../models/cps-scenario.models/cps-scenario.models'; +import { CpsTelemetryMetadata } from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { CPS_DEFAULT_REDACT_CONFIG } from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; +import { CpsScenario, CpsScenarioDeps } from './cps-scenario'; +import { CpsTelemetryMonitor } from '../../services/cps-telemetry-monitor.service/cps-telemetry-monitor.service'; + +/** Captures what the scenario emitted, so a test can assert on it. */ +class RecordingSink extends CpsTelemetrySink { + readonly events: { eventType: string; payload: Record<string, unknown> }[] = + []; + + sessionId: string | undefined = 'test-session'; + userId: string | undefined; + + record(eventType: string, payload: object): void { + this.events.push({ + eventType, + payload: payload as Record<string, unknown> + }); + } + + recordError(): void {} + + getSessionId(): string | undefined { + return this.sessionId; + } + + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + getUserId(): string | undefined { + return this.userId; + } + + flush(): void {} +} + +function createDeps(overrides: Partial<CpsScenarioDeps> = {}): { + deps: CpsScenarioDeps; + sink: RecordingSink; + settled: CpsScenarioRecord[]; +} { + const sink = new RecordingSink(); + const settled: CpsScenarioRecord[] = []; + const deps: CpsScenarioDeps = { + identity: { + application: 'test-app', + environment: 'test', + version: '1.0.0' + }, + scenarioConfig: { ...CPS_DEFAULT_TELEMETRY_CONFIG.scenario }, + redact: CPS_DEFAULT_REDACT_CONFIG, + sink, + onSettled: (_id, record) => settled.push(record), + monitor: new CpsTelemetryMonitor(), + ...overrides + }; + return { deps, sink, settled }; +} + +describe('CpsScenario (direct construction)', () => { + describe('construction', () => { + it('should construct from a plain CpsScenarioDeps object, with no Angular DI involved', () => { + const { deps } = createDeps(); + expect(() => new CpsScenario({ name: 'checkout' }, deps)).not.toThrow(); + }); + + it('should assign a unique id to every instance', () => { + const { deps } = createDeps(); + const a = new CpsScenario({ name: 'checkout' }, deps); + const b = new CpsScenario({ name: 'checkout' }, deps); + expect(a.id).not.toBe(b.id); + }); + + it('should expose the name it was started with', () => { + const { deps } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + expect(scenario.name).toBe('checkout'); + }); + + it('should report no status and isSettled=false before settling', () => { + const { deps } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + expect(scenario.status).toBeUndefined(); + expect(scenario.isSettled).toBe(false); + }); + }); + + describe('steps', () => { + it('should record a scenario-start boundary even with no steps taken', () => { + const { deps } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + const record = scenario.toRecord(); + + expect(record.steps).toHaveLength(1); + expect(record.steps[0]).toMatchObject({ + name: 'scenario-start', + status: 'success' + }); + }); + + it('should close the previous step when the next one opens', () => { + const { deps } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.step('fetch'); + scenario.step('render'); + + const fetchStep = scenario + .toRecord() + .steps.find((s) => s.name === 'fetch'); + expect(fetchStep?.status).toBe('success'); + }); + + it('should mark a step failed via failStep without settling the scenario', () => { + const { deps } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.step('fetch'); + scenario.failStep(new Error('network down')); + + expect(scenario.isSettled).toBe(false); + const fetchStep = scenario + .toRecord() + .steps.find((s) => s.name === 'fetch'); + expect(fetchStep?.status).toBe('failure'); + expect(fetchStep?.error).toMatchObject({ message: 'network down' }); + }); + + it('should ignore mutation calls once settled', () => { + const { deps } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.complete(); + + expect(() => scenario.step('too-late')).not.toThrow(); + expect(scenario.toRecord().stepCount).toBe(0); + }); + + it("should not let a mutated mid-flight toRecord() snapshot change what's later emitted", () => { + const { deps, sink } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.step('fetch', { count: 1 }); + scenario.setData({ owner: 'checkout-team' }); + + const snapshot = scenario.toRecord(); + const fetchStep = snapshot.steps.find((s) => s.name === 'fetch'); + (fetchStep!.metadata as CpsTelemetryMetadata).count = 999; + (snapshot.metadata as CpsTelemetryMetadata).owner = 'hijacked'; + + scenario.complete(); + + const emitted = sink.events[0].payload as unknown as CpsScenarioRecord; + const emittedFetchStep = emitted.steps.find((s) => s.name === 'fetch'); + expect(emittedFetchStep?.metadata?.count).toBe(1); + expect(emitted.metadata?.owner).toBe('checkout-team'); + }); + }); + + describe('settle', () => { + it('should emit exactly one scenario event to the sink', () => { + const { deps, sink } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.complete(); + + expect(sink.events).toHaveLength(1); + expect(sink.events[0].eventType).toBe('com.cps.scenario'); + }); + + it('should notify onSettled with the final record', () => { + const { deps, settled } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.complete({ message: 'done' }); + + expect(settled).toHaveLength(1); + expect(settled[0].status).toBe('success'); + expect(settled[0].message).toBe('done'); + }); + + it('should keep message and reason independent, never merged', () => { + const { deps } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.complete({ message: 'human note', reason: 'cache-hit' }); + + const record = scenario.toRecord(); + expect(record.message).toBe('human note'); + expect(record.reason).toBe('cache-hit'); + }); + + it('should ignore a second settle call after the first', () => { + const { deps, settled } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.complete(); + scenario.fail({ error: new Error('too late') }); + + expect(settled).toHaveLength(1); + expect(scenario.status).toBe('success'); + }); + + it('should attach the normalized error to the root record even for a non-failure status', () => { + const { deps } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.settle('success', {}, new Error('odd but allowed')); + + const record = scenario.toRecord(); + expect(record.status).toBe('success'); + expect(record.error).toMatchObject({ message: 'odd but allowed' }); + + const scenarioEndStep = record.steps.find( + (s) => s.name === 'scenario-end' + ); + expect(scenarioEndStep?.error).toBeUndefined(); + }); + + it('should retain a legitimately falsy thrown value, not treat it as no error', () => { + const { deps } = createDeps(); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.fail({ error: 0 }); + + const record = scenario.toRecord(); + expect(record.error).toBeDefined(); + expect(record.error).toMatchObject({ name: 'number' }); + }); + }); + + describe('metadata cap across merges', () => { + it('should cap accumulated setData() calls at maxKeys, not double-count independently capped bags', () => { + const consoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + const { deps } = createDeps({ + redact: { ...CPS_DEFAULT_REDACT_CONFIG, maxKeys: 1 } + }); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.setData({ a: 1 } as CpsTelemetryMetadata); + scenario.setData({ b: 2 } as CpsTelemetryMetadata); + + const metadata = scenario.toRecord().metadata; + expect(Object.keys(metadata ?? {})).toHaveLength(1); + expect(metadata).toEqual({ a: 1 }); + + consoleWarn.mockRestore(); + }); + + it("should cap settle()'s outcome metadata merged into existing scenario metadata", () => { + const consoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + const { deps } = createDeps({ + redact: { ...CPS_DEFAULT_REDACT_CONFIG, maxKeys: 1 } + }); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.setData({ a: 1 } as CpsTelemetryMetadata); + scenario.complete({ metadata: { b: 2 } as CpsTelemetryMetadata }); + + const metadata = scenario.toRecord().metadata; + expect(Object.keys(metadata ?? {})).toHaveLength(1); + expect(metadata).toEqual({ a: 1 }); + + consoleWarn.mockRestore(); + }); + + it("should cap a step's initial and closing metadata combined, not each independently", () => { + const consoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + const { deps } = createDeps({ + redact: { ...CPS_DEFAULT_REDACT_CONFIG, maxKeys: 1 } + }); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.step('fetch', { a: 1 } as CpsTelemetryMetadata); + scenario.endStep({ metadata: { b: 2 } as CpsTelemetryMetadata }); + + const fetchStep = scenario + .toRecord() + .steps.find((s) => s.name === 'fetch'); + expect(Object.keys(fetchStep?.metadata ?? {})).toHaveLength(1); + expect(fetchStep?.metadata).toEqual({ a: 1 }); + + consoleWarn.mockRestore(); + }); + }); + + describe('CpsScenarioDeps contract', () => { + it('should read identity from the supplied deps, not a default', () => { + const { deps } = createDeps({ + identity: { + application: 'custom-app', + environment: 'qa', + version: '9.9.9' + } + }); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + expect(scenario.toRecord().application).toBe('custom-app'); + }); + + it("should call onSettled exactly once, with this scenario's own id", () => { + const calls: string[] = []; + const { deps } = createDeps({ onSettled: (id) => calls.push(id) }); + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + scenario.complete(); + + expect(calls).toEqual([scenario.id]); + }); + + it('should read the session and user id from the supplied sink', () => { + const { deps, sink } = createDeps(); + sink.sessionId = 's-1'; + sink.userId = 'u-1'; + const scenario = new CpsScenario({ name: 'checkout' }, deps); + + const record = scenario.toRecord(); + expect(record.sessionId).toBe('s-1'); + expect(record.userId).toBe('u-1'); + }); + + it('should redact metadata using the supplied redact config, not the library default', () => { + const consoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + const { deps } = createDeps({ + redact: { ...CPS_DEFAULT_REDACT_CONFIG, maxKeys: 1 } + }); + const scenario = new CpsScenario( + { name: 'checkout', metadata: { a: 1, b: 2 } as CpsTelemetryMetadata }, + deps + ); + + expect(Object.keys(scenario.toRecord().metadata ?? {})).toHaveLength(1); + + consoleWarn.mockRestore(); + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/scenario/cps-scenario/cps-scenario.ts b/projects/cps-telemetry/src/lib/scenario/cps-scenario/cps-scenario.ts new file mode 100644 index 000000000..1922a9cea --- /dev/null +++ b/projects/cps-telemetry/src/lib/scenario/cps-scenario/cps-scenario.ts @@ -0,0 +1,875 @@ +import { CpsTelemetryIdentity } from '../../config/cps-telemetry-common.config/cps-telemetry-common.config'; +import { CpsScenarioTelemetryConfig } from '../../config/cps-scenario-telemetry.config/cps-scenario-telemetry.config'; +import { + CpsScenarioAggregate, + CpsScenarioName, + CpsScenarioOptions, + CpsScenarioOutcome, + CpsScenarioRecord, + CpsScenarioStatus, + CpsScenarioStep, + CpsScenarioStepDetail, + CpsScenarioStepEvent, + CpsScenarioStepStatus, + CpsStepName +} from '../../models/cps-scenario.models/cps-scenario.models'; +import { + cpsEventTypes, + CpsTelemetryEventTypes, + CpsTelemetryMetadata +} from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { CpsTelemetryMonitor } from '../../services/cps-telemetry-monitor.service/cps-telemetry-monitor.service'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { + cpsDebugWrite, + cpsIsDebugEnabled +} from '../../utils/cps-debug-flag.util/cps-debug-flag.util'; +import { + CpsRedactConfig, + cpsMergeMetadata, + cpsNormalizeError, + cpsRedactMetadata, + cpsScrubString +} from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; +import { + cpsDeepClone, + cpsElapsedNow, + cpsEpochToPerf, + cpsNow, + cpsSafe, + cpsSafeVoid, + cpsUuid +} from '../../utils/cps-telemetry-safe.util/cps-telemetry-safe.util'; +import { + cpsClearMarks, + cpsMark, + cpsMarkName, + cpsMeasure +} from '../../utils/cps-user-timings-internal.util/cps-user-timings-internal.util'; + +/** + * Collaborators a scenario needs. Supplied by + * {@link CpsScenarioTelemetryService}; not part of the public API. + */ +export interface CpsScenarioDeps { + /** The application's identity, shared by every telemetry concern. */ + identity: CpsTelemetryIdentity; + /** Scenario-specific tuning — timeouts, step cap, User Timing marks. */ + scenarioConfig: CpsScenarioTelemetryConfig; + /** Redaction settings, shared by every telemetry concern. */ + redact: CpsRedactConfig; + sink: CpsTelemetrySink; + /** Called once with the scenario's id when it reaches a terminal state. */ + onSettled: (scenarioId: string, record: CpsScenarioRecord) => void; + /** Told about every event right after it is handed to the sink. */ + monitor: CpsTelemetryMonitor; +} + +/** The two events a scenario sends: its settled record, and a closed step. */ +type CpsScenarioEmission = + | { kind: 'scenario'; payload: CpsScenarioRecord } + | { kind: 'scenario-step'; payload: CpsScenarioStepEvent }; + +/** Boundary names used for User Timing marks (see `timingMark`). */ +const START_MARK = 'start'; +const SETTLE_MARK = 'settle'; + +const MAX_TIMEOUT_MS = 2_147_483_647; + +/** + * A single in-flight user journey or feature execution. + * + * Each call to {@link CpsScenarioTelemetryService.start} returns a fresh + * instance, so scenarios can run concurrently without interfering. + * + * @example + * ```typescript + * try { + * scenario.step('fetch-data'); + * const rows = await this.api.fetchCustomers(); + * scenario.complete({ metadata: { rowCount: rows.length } }); + * } catch (error) { + * scenario.fail({ error }); + * } + * ``` + * + * @group Classes + */ +export class CpsScenario { + private readonly _id = cpsUuid(); + private readonly startedAt: number; + private readonly _startTime: number; + private readonly steps: CpsScenarioStep[] = []; + private readonly metadata: CpsTelemetryMetadata; + + /** Running totals for {@link aggregateStart} / {@link aggregateEnd}. */ + private readonly aggregates = new Map< + CpsStepName, + { total: number; callCount: number; openedAt?: number } + >(); + + /** User Timing mark names created so far, cleared from the buffer at settle. */ + private readonly timingMarks: string[] = []; + + /** Event types for the application's configured namespace. */ + private readonly eventTypes: CpsTelemetryEventTypes; + + private _status?: CpsScenarioStatus; + private _stepCount = 0; + private openStep?: CpsScenarioStep; + /** Whether the open step counted against {@link CpsScenarioTelemetryConfig.maxSteps}. */ + private openStepIncluded = false; + private previousStep?: CpsStepName; + private lastTimingMark: string; + private timeoutHandle?: ReturnType<typeof setTimeout>; + /** See {@link scheduleMarkCleanupFallback}. */ + private markCleanupTimer?: ReturnType<typeof setTimeout>; + + private _elapsed = 0; + private settleOutcome?: CpsScenarioOutcome; + private settleError?: unknown; + /** Scrubbed once in {@link settle}, reused by {@link toRecord} — see that method's doc comment. */ + private settledMessage?: string; + /** Scrubbed once in {@link settle}, reused by {@link toRecord} — see that method's doc comment. */ + private settledReason?: string; + + /** + * Scrubbed once here and reused by {@link toRecord} and + * {@link measureScenarioTiming}. `route` is expected to be a template + * (`/customers/:id`); the library cannot enforce that. + */ + private readonly feature?: string; + private readonly operation?: string; + private readonly route?: string; + + /** + * Whether User Timing entries should be produced. + * + * The debug flag overrides the config, so a developer can + * get timeline entries out of an already-deployed build. + */ + private readonly userTimingsEnabled: boolean; + + constructor( + private readonly options: CpsScenarioOptions, + private readonly deps: CpsScenarioDeps + ) { + this.eventTypes = cpsEventTypes(deps.identity.eventNamespace); + this.userTimingsEnabled = + deps.scenarioConfig.userTimings || cpsIsDebugEnabled('debugScenario'); + this.metadata = cpsSafe( + 'scenario.construct', + () => cpsRedactMetadata(options.metadata, deps.redact) ?? {}, + {} + ); + this.feature = options.feature + ? cpsScrubString(options.feature, deps.redact) + : undefined; + this.operation = options.operation + ? cpsScrubString(options.operation, deps.redact) + : undefined; + this.route = options.route + ? cpsScrubString(options.route, deps.redact) + : undefined; + + const backdated = + options.startedAt !== undefined + ? cpsEpochToPerf(options.startedAt) + : undefined; + + this.startedAt = backdated ?? cpsNow(); + this._startTime = Date.now() - Math.round(cpsNow() - this.startedAt); + + this.steps.push({ + name: 'scenario-start', + startOffset: 0, + endOffset: 0, + stepDelta: 0, + elapsed: Math.round(cpsElapsedNow()), + status: 'success' + }); + + this.lastTimingMark = this.timingMark(START_MARK); + this.scheduleTimeout(); + } + + /** + * The scenario's unique identifier and correlation id — pass it to + * {@link CpsLoggerService} calls, BI events, and backend requests to + * reassemble a journey across frontend and backend telemetry. + */ + get id(): string { + return this._id; + } + + /** The scenario name supplied at start. */ + get name(): CpsScenarioName { + return this.options.name; + } + + /** + * The settled status, or `undefined` while the scenario is still running. + * + * Use {@link isSettled} to check whether it is done. + */ + get status(): CpsScenarioStatus | undefined { + return this._status; + } + + /** Whether the scenario has settled. */ + get isSettled(): boolean { + return this._status !== undefined; + } + + /** + * Duration in milliseconds — how long the scenario has run, or its final + * duration once settled. + * + * Named `delta`, not `elapsed`: the record's `elapsed` field means + * milliseconds since the page loaded, a different value. + */ + get delta(): number { + return this.isSettled ? this._elapsed : cpsNow() - this.startedAt; + } + + /** + * Opens a step, implicitly closing the previous one as completed. + * + * @param name the step name, declared in {@link CpsScenarioSteps} + * @param metadata attributes recorded on the step + * @returns this scenario, for chaining + */ + step(name: CpsStepName, metadata?: CpsTelemetryMetadata): this { + return this.mutate('step', () => { + this.closeOpenStep('success'); + + this._stepCount++; + this.openStepIncluded = + this._stepCount <= this.deps.scenarioConfig.maxSteps; + + const step: CpsScenarioStep = { + name, + startOffset: Math.round(cpsNow() - this.startedAt), + metadata: this.openStepIncluded + ? cpsRedactMetadata(metadata, this.deps.redact) + : undefined + }; + + this.openStep = step; + if (this.openStepIncluded) { + this.steps.push(step); + } + }); + } + + /** + * Closes the open step as completed. + * + * Rarely needed — opening the next step or settling the scenario closes it + * automatically. Use it when the step ends well before the next one starts. + * + * @param detail optional note and attributes + * @returns this scenario, for chaining + */ + endStep(detail?: CpsScenarioStepDetail): this { + return this.mutate('endStep', () => this.closeOpenStep('success', detail)); + } + + /** + * Closes the open step as failed, leaving the scenario itself in progress. + * + * Use when a step fails but the journey recovers — a retried request, an + * optional resource that could not be loaded. + * + * @param error the thrown value + * @param detail optional note and attributes + * @returns this scenario, for chaining + */ + failStep(error: unknown, detail?: CpsScenarioStepDetail): this { + return this.mutate('failStep', () => + this.closeOpenStep('failure', detail, error) + ); + } + + /** + * Merges attributes into the scenario record while it is still running. + * + * Facts worth attaching to the whole journey — a row count, which strategy was + * chosen, whether a cache was warm — are usually learned partway through, + * after `start` and before any outcome is known. + * + * @param metadata attributes merged into the scenario record + * @returns this scenario, for chaining + */ + setData(metadata: CpsTelemetryMetadata): this { + return this.mutate('setData', () => { + const safe = cpsRedactMetadata(metadata, this.deps.redact); + if (safe) { + cpsMergeMetadata(this.metadata, safe, this.deps.redact); + } + }); + } + + /** + * Starts timing one call of a repeatedly-invoked operation. + * + * Use for an operation that happens many times, where a step would bury + * the scenario in noise. A second call for the same name before its + * matching {@link aggregateEnd} is ignored. + * + * @param name the operation name, declared in {@link CpsScenarioSteps} + * @returns this scenario, for chaining + */ + aggregateStart(name: CpsStepName): this { + return this.mutate('aggregateStart', () => { + const entry = this.aggregates.get(name) ?? { total: 0, callCount: 0 }; + if (entry.openedAt === undefined) { + entry.openedAt = cpsNow(); + } + this.aggregates.set(name, entry); + }); + } + + /** + * Stops timing one call and adds it to the running total. + * + * Ignored when there is no matching {@link aggregateStart}, so an early return + * inside the measured operation cannot corrupt the total. + * + * @param name the operation name, declared in {@link CpsScenarioSteps} + * @returns this scenario, for chaining + */ + aggregateEnd(name: CpsStepName): this { + return this.mutate('aggregateEnd', () => { + const entry = this.aggregates.get(name); + if (!entry || entry.openedAt === undefined) { + return; + } + + entry.total += cpsNow() - entry.openedAt; + entry.callCount++; + entry.openedAt = undefined; + }); + } + + /** + * Settles the scenario successfully. + * + * @param outcome optional status code, note and attributes + */ + complete(outcome?: CpsScenarioOutcome): void { + this.settle('success', outcome); + } + + /** + * Settles the scenario as `incomplete` — it neither succeeded nor broke. + * + * For expected code paths that do not reach the goal. Kept apart from + * `failed` and `cancelled` so it does not skew either. + * + * @param outcome optional status code, message, reason and attributes + */ + incomplete(outcome?: CpsScenarioOutcome): void { + this.settle('incomplete', outcome); + } + + /** + * Settles the scenario as failed. + * + * @param outcome optional status code, note, attributes, and the thrown + * `error` — normalized before emission + */ + fail(outcome?: CpsScenarioOutcome): void { + this.settle('failure', outcome, outcome?.error); + } + + /** + * Settles the scenario as abandoned — it stopped being relevant. + * + * Covers the user or page navigating away. A scenario that times out + * settles as `timeout` instead, not `abandoned`. + * + * Records `metadata.abandonedBy: 'caller'`. + * + * @param outcome optional status code, message, reason and additional metadata + */ + cancel(outcome?: CpsScenarioOutcome): void { + this.settle('abandoned', { + ...outcome, + metadata: { + ...outcome?.metadata, + // Last, so caller-supplied metadata cannot override it. + abandonedBy: 'caller' + } + }); + } + + /** + * Builds the payload that is emitted to the sink. + * + * @returns the scenario record as it currently stands. A snapshot taken + * mid-flight can have fewer than two steps, since `scenario-end` is only + * written at settlement. + */ + toRecord(): CpsScenarioRecord { + const { redact } = this.deps; + const settled = this.isSettled; + + const aggregates = this.collectAggregates(); + + const record: CpsScenarioRecord = { + scenarioId: this._id, + parentScenarioId: this.options.parentScenarioId, + scenarioName: this.options.name, + feature: this.feature, + operation: this.operation, + route: this.route, + status: this._status, + startTime: new Date(this._startTime).toISOString(), + endTime: settled + ? new Date(this._startTime + Math.round(this._elapsed)).toISOString() + : undefined, + delta: Math.round(this.delta), + elapsed: Math.round(cpsElapsedNow()), + stepCount: this._stepCount, + steps: cpsDeepClone(this.steps), + previousStep: this.previousStep, + aggregates: aggregates.length ? aggregates : undefined, + metadata: Object.keys(this.metadata).length + ? cpsDeepClone(this.metadata) + : undefined, + application: this.deps.identity.application, + sessionId: this.sessionId(), + userId: this.userId() + }; + + if (this._stepCount > this.deps.scenarioConfig.maxSteps) { + record.exceededStepsLimit = true; + } + + if (this.settleOutcome?.statusCode !== undefined) { + record.statusCode = this.settleOutcome.statusCode; + } + if (this.settledMessage) { + record.message = this.settledMessage; + } + if (this.settledReason) { + record.reason = this.settledReason; + } + if (this.settleError !== undefined) { + record.error = cpsNormalizeError(this.settleError, redact); + } + + return record; + } + + /** + * Settles the scenario into a status supplied as data. + * + * **Prefer {@link complete}, {@link fail}, {@link cancel} or + * {@link incomplete}** — they name the outcome at the call site so it can + * be found by searching. Use this form for adapters that map an external + * status onto a scenario without knowing the outcome in advance. + * + * @param status the terminal state to settle into + * @param outcome optional status code, note and attributes + * @param error the thrown value, when settling as `failed` + */ + settle( + status: CpsScenarioStatus, + outcome?: CpsScenarioOutcome, + error?: unknown + ): void { + cpsSafeVoid(`scenario.${status}`, () => { + if (this.isSettled) { + return; + } + + this.clearTimeout(); + this.clearMarkCleanupTimer(); + this._elapsed = cpsNow() - this.startedAt; + this._status = status; + + const { message, reason } = outcome ?? {}; + const { redact } = this.deps; + + const resolvedMessage = message + ? cpsScrubString(message, redact) + : undefined; + const resolvedReason = reason + ? cpsScrubString(reason, redact) + : undefined; + const resolvedMetadata = cpsRedactMetadata(outcome?.metadata, redact); + + this.closeOpenStep( + status, + { + message: resolvedMessage, + reason: resolvedReason, + metadata: resolvedMetadata + }, + error, + true + ); + + this.steps.push({ + name: 'scenario-end', + startOffset: Math.round(this._elapsed), + endOffset: Math.round(this._elapsed), + stepDelta: 0, + elapsed: Math.round(cpsElapsedNow()), + status, + ...(resolvedMessage && { message: resolvedMessage }), + ...(resolvedReason && { reason: resolvedReason }), + ...(resolvedMetadata && { metadata: resolvedMetadata }), + ...(status === 'failure' && + error !== undefined && { + error: cpsNormalizeError(error, redact) + }) + }); + + this.settleOutcome = outcome; + this.settleError = error; + this.settledMessage = resolvedMessage; + this.settledReason = resolvedReason; + + if (resolvedMetadata) { + cpsMergeMetadata(this.metadata, resolvedMetadata, redact); + } + + this.measureScenarioTiming(); + + const record = this.toRecord(); + this.deps.onSettled(this._id, record); + this.emit( + { kind: 'scenario', payload: record }, + `${status} in ${Math.round(this._elapsed)}ms` + ); + }); + } + + /** The active telemetry sink's session id, when it has one. */ + private sessionId(): string | undefined { + return cpsSafe( + 'scenario.getSessionId', + () => this.deps.sink.getSessionId(), + undefined + ); + } + + /** The active telemetry sink's application user id, when one is signed in. */ + private userId(): string | undefined { + return cpsSafe( + 'scenario.getUserId', + () => this.deps.sink.getUserId(), + undefined + ); + } + + /** Snapshots the aggregate totals, closing any call still open. */ + private collectAggregates(): CpsScenarioAggregate[] { + const result: CpsScenarioAggregate[] = []; + const now = this.isSettled ? this.startedAt + this._elapsed : cpsNow(); + + for (const [name, entry] of this.aggregates) { + let total = entry.total; + let callCount = entry.callCount; + + if (entry.openedAt !== undefined) { + total += now - entry.openedAt; + callCount++; + } + + result.push({ name, elapsed: Math.round(total), callCount }); + } + + return result; + } + + /** + * @param alreadyRedacted `true` when `detail`'s fields are already + * scrubbed, so they are not redacted a second time. Defaults to `false`. + */ + private closeOpenStep( + status: CpsScenarioStepStatus, + detail?: CpsScenarioStepDetail, + error?: unknown, + alreadyRedacted = false + ): void { + const step = this.openStep; + if (!step) { + return; + } + this.openStep = undefined; + + const { redact } = this.deps; + step.endOffset = Math.round(cpsNow() - this.startedAt); + step.stepDelta = step.endOffset - step.startOffset; + step.elapsed = Math.round(cpsElapsedNow()); + step.status = status; + + if (detail?.message) { + step.message = alreadyRedacted + ? detail.message + : cpsScrubString(detail.message, redact); + } + if (detail?.reason) { + step.reason = alreadyRedacted + ? detail.reason + : cpsScrubString(detail.reason, redact); + } + if (detail?.metadata) { + step.metadata = cpsMergeMetadata( + step.metadata ?? {}, + alreadyRedacted + ? detail.metadata + : cpsRedactMetadata(detail.metadata, redact), + redact + ); + } + if (status === 'failure' && error !== undefined) { + step.error = cpsNormalizeError(error, redact); + } + + // The two synthetic boundary markers never pass through here, so this + // is always a real step name. + const realStepName = step.name as CpsStepName; + this.measureStepTiming(realStepName); + this.previousStep = realStepName; + + if (this.deps.scenarioConfig.emitLifecycleEvents && this.openStepIncluded) { + const stepEvent: CpsScenarioStepEvent = { + scenarioId: this._id, + scenarioName: this.options.name, + application: this.deps.identity.application, + sessionId: this.sessionId(), + userId: this.userId(), + ...step + }; + this.emit( + { kind: 'scenario-step', payload: stepEvent }, + `step ${step.name}` + ); + } + } + + /** + * Records a `performance.mark` for a boundary and remembers its name so + * it can be cleared at settle. + * + * @param boundary the boundary name — `start`, a step name, or `settle` + * @returns the mark name, for use as a measure endpoint + */ + private timingMark(boundary: string): string { + const name = cpsMarkName( + this.deps.identity.application, + this.options.name, + this._id, + boundary + ); + + if (this.userTimingsEnabled) { + cpsMark(name); + this.timingMarks.push(name); + } + + return name; + } + + /** + * Records the DevTools timeline entry for a step that has just closed. + * + * Purely instrumentation — the step itself was already recorded by + * {@link closeOpenStep}, which is what reaches the telemetry sink. + */ + private measureStepTiming(stepName: CpsStepName): void { + if (!this.userTimingsEnabled) { + return; + } + + const endMark = this.timingMark(stepName); + cpsMeasure( + `${this.options.name} [${stepName}]`, + this.lastTimingMark, + endMark + ); + this.lastTimingMark = endMark; + } + + /** Measures the whole scenario, then drops its marks from the buffer. */ + private measureScenarioTiming(): void { + if (!this.userTimingsEnabled) { + return; + } + + const startMark = cpsMarkName( + this.deps.identity.application, + this.options.name, + this._id, + START_MARK + ); + const endMark = this.timingMark(SETTLE_MARK); + + const feature = this.feature ? ` (${this.feature})` : ''; + cpsMeasure(`${this.options.name}${feature}`, startMark, endMark); + + cpsClearMarks(this.timingMarks); + this.timingMarks.length = 0; + } + + private scheduleTimeout(): void { + const timeoutMs = + this.options.timeoutMs ?? this.deps.scenarioConfig.defaultTimeoutMs; + + if (!timeoutMs || timeoutMs <= 0) { + this.scheduleMarkCleanupFallback(); + return; + } + + const remainingMs = Math.max(0, timeoutMs - (cpsNow() - this.startedAt)); + + this.timeoutHandle = setTimeout( + () => { + this.timeoutHandle = undefined; + if (remainingMs > MAX_TIMEOUT_MS) { + this.scheduleTimeout(); + return; + } + this.settle('timeout', { + message: `Scenario did not settle within ${timeoutMs}ms` + }); + }, + Math.min(remainingMs, MAX_TIMEOUT_MS) + ); + } + + private clearTimeout(): void { + this.timeoutHandle = CpsScenario.clearHandle(this.timeoutHandle); + } + + /** + * Defensive backstop for a scenario with no business timeout: clears its + * User Timing marks after {@link CpsScenarioTelemetryConfig.markCleanupFallbackMs}, + * independent of whether the scenario ever settles. + * + * A no-op when `userTimings` is off or `markCleanupFallbackMs` is `0`. + * Measured from the scenario's start and, like {@link scheduleTimeout}, + * rescheduled in hops of at most `setTimeout`'s 32-bit limit, so a longer + * fallback is honoured rather than firing after about 24.8 days. + * + * This clears marks only — it does not settle the scenario. A scenario + * left unsettled this way never fires {@link CpsScenarioDeps.onSettled}, + * so it stays in {@link CpsScenarioTelemetryService}'s active registry for + * the life of the page. + */ + private scheduleMarkCleanupFallback(): void { + const fallbackMs = this.deps.scenarioConfig.markCleanupFallbackMs; + if (!this.userTimingsEnabled || !fallbackMs || fallbackMs <= 0) { + return; + } + + const remainingMs = Math.max(0, fallbackMs - (cpsNow() - this.startedAt)); + + this.markCleanupTimer = setTimeout( + () => { + this.markCleanupTimer = undefined; + if (remainingMs > MAX_TIMEOUT_MS) { + this.scheduleMarkCleanupFallback(); + return; + } + cpsClearMarks(this.timingMarks); + this.timingMarks.length = 0; + }, + Math.min(remainingMs, MAX_TIMEOUT_MS) + ); + } + + private clearMarkCleanupTimer(): void { + this.markCleanupTimer = CpsScenario.clearHandle(this.markCleanupTimer); + } + + /** + * Clears a scheduled `setTimeout` if one is pending, otherwise a no-op. + * + * @param handle the timer handle, or `undefined` if nothing is scheduled + * @returns `undefined`, so a call site can reassign its field directly: + * `this.timeoutHandle = CpsScenario.clearHandle(this.timeoutHandle)` + */ + private static clearHandle( + handle: ReturnType<typeof setTimeout> | undefined + ): undefined { + if (handle !== undefined) { + clearTimeout(handle); + } + return undefined; + } + + /** + * Applies a mutation to a still-open scenario, and returns it for + * chaining. Fail-open, and ignored once the scenario has settled. + * + * Settling methods do not use it — they end the scenario, not mutate it. + * + * @param operation the method name, for error reports + * @param apply the mutation, run only while the scenario is open + * @returns this scenario + */ + private mutate(operation: string, apply: () => void): this { + cpsSafeVoid(`scenario.${operation}`, () => { + if (this.isSettled) { + return; + } + apply(); + }); + return this; + } + + /** + * Sends a payload to the sink, first logging that exact same object if + * `debugScenario` is on, then telling the monitor it was handed over. + * + * The only place any of the three happens, so console output, sink output + * and what an observer sees can never drift apart: one object, one call + * site. A sink that throws leaves the monitor untold, since nothing was + * handed over. + * + * @param emission the payload and which of the two events it is + * @param summary a short human-readable prefix for the console line + */ + private emit(emission: CpsScenarioEmission, summary: string): void { + const eventType = + emission.kind === 'scenario' + ? this.eventTypes.scenario + : this.eventTypes.scenarioStep; + + cpsDebugWrite('debugScenario', () => + writeToConsole(summary, eventType, emission.payload) + ); + this.deps.sink.record(eventType, emission.payload); + this.deps.monitor.publish({ + ...emission, + eventType, + destination: 'sink', + origin: { forwarded: false } + }); + } +} + +/** + * Prints the payload exactly as the sink receives it. + * + * Prefixed with the application - in a composed page every realm writes to + * the one console - then the concern and the scenario's name, the same + * shape the logger and BI writers use. + */ +function writeToConsole( + summary: string, + eventType: string, + payload: CpsScenarioEmission['payload'] +): void { + // eslint-disable-next-line no-console + console.log( + `[${payload.application}][scenario] ${payload.scenarioName} ${summary} -> ${eventType}`, + payload + ); +} diff --git a/projects/cps-telemetry/src/lib/services/cps-bi-telemetry.service/cps-bi-telemetry.service.spec.ts b/projects/cps-telemetry/src/lib/services/cps-bi-telemetry.service/cps-bi-telemetry.service.spec.ts new file mode 100644 index 000000000..9f5e5a4ba --- /dev/null +++ b/projects/cps-telemetry/src/lib/services/cps-bi-telemetry.service/cps-bi-telemetry.service.spec.ts @@ -0,0 +1,706 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + provideCpsTelemetry, + withBiEvents, + withRedaction, + withScenarios +} from '../../providers/cps-telemetry-common.providers/cps-telemetry-common.providers'; +import { CpsBiEvent } from '../../models/cps-bi.models/cps-bi.models'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { + CPS_LOG_API_PROVIDER, + CpsLogApiProvider, + CpsLogQuery +} from '../../providers/cps-log-api.provider/cps-log-api.provider'; +import { CPS_REDACTED } from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; +import { CpsBiTelemetryService } from './cps-bi-telemetry.service'; +import { CpsScenarioTelemetryService } from '../cps-scenario-telemetry.service/cps-scenario-telemetry.service'; +import { + CPS_TELEMETRY_EVENT_TYPE, + CpsTelemetryError, + CpsTelemetryMetadata +} from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { CpsLogRecord } from '../../models/cps-log.models/cps-log.models'; + +/** Captures what the library emitted, so a test can assert on it. */ +@Injectable() +class RecordingSink extends CpsTelemetrySink { + readonly events: { + eventType: string; + payload: Record<string, unknown>; + metadata?: CpsTelemetryMetadata; + }[] = []; + + readonly errors: CpsTelemetryError[] = []; + readonly flushes: boolean[] = []; + userId?: string; + sessionId: string | undefined = 'test-session'; + + record( + eventType: string, + payload: object, + metadata?: CpsTelemetryMetadata + ): void { + this.events.push({ + eventType, + payload: payload as Record<string, unknown>, + metadata + }); + } + + recordError(error: CpsTelemetryError): void { + this.errors.push(error); + } + + getSessionId(): string | undefined { + return this.sessionId; + } + + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + getUserId(): string | undefined { + return this.userId; + } + + flush(beacon = false): void { + this.flushes.push(beacon); + } + + ofType(eventType: string) { + return this.events.filter((event) => event.eventType === eventType); + } +} + +/** Fails on every call, to prove telemetry cannot break the caller. */ +@Injectable() +class ThrowingSink extends CpsTelemetrySink { + record(): never { + throw new Error('sink is broken'); + } + + recordError(): never { + throw new Error('sink is broken'); + } + + getSessionId(): never { + throw new Error('sink is broken'); + } + + setUserId(): never { + throw new Error('sink is broken'); + } + + getUserId(): never { + throw new Error('sink is broken'); + } + + flush(): never { + throw new Error('sink is broken'); + } +} + +/** Keeps every batch, so a test can assert on what was shipped. */ +@Injectable() +class RecordingLogApi implements CpsLogApiProvider { + readonly records: CpsLogRecord[] = []; + + send(record: CpsLogRecord): void { + this.records.push(record); + } + + query(filter: CpsLogQuery): Promise<CpsLogRecord[]> { + let found = this.records; + if (filter.correlationId) { + found = found.filter((r) => r.correlationId === filter.correlationId); + } + if (filter.logger) { + found = found.filter((r) => r.logger === filter.logger); + } + if (filter.limit !== undefined) { + found = found.slice(0, filter.limit); + } + return Promise.resolve(found); + } +} + +describe('CpsBiTelemetryService', () => { + let service: CpsBiTelemetryService; + let sink: RecordingSink; + + function configure(sinkClass: unknown = RecordingSink): void { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withScenarios({ defaultTimeoutMs: 0 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + sinkClass as never, + { provide: CpsTelemetrySink, useExisting: sinkClass as never } + ] + }); + service = TestBed.inject(CpsBiTelemetryService); + } + + function lastEvent(): CpsBiEvent { + const events = sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi); + return events[events.length - 1].payload as unknown as CpsBiEvent; + } + + beforeEach(() => { + localStorage.clear(); + configure(); + sink = TestBed.inject(RecordingSink); + }); + + afterEach(() => { + localStorage.clear(); + jest.restoreAllMocks(); + }); + + describe('event creation', () => { + it('should record the application-supplied event name and metadata', () => { + service.track('export_clicked', { + exportType: 'csv', + source: 'customer-table' + }); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(1); + expect(lastEvent()).toMatchObject({ + eventName: 'export_clicked', + metadata: { exportType: 'csv', source: 'customer-table' } + }); + }); + + it('should work with no metadata at all', () => { + service.track('modal_opened'); + expect(lastEvent()).toMatchObject({ eventName: 'modal_opened' }); + expect(lastEvent().metadata).toBeUndefined(); + }); + + it('should carry an ISO-8601 event time', () => { + service.track('tab_selected'); + expect(lastEvent().eventTime).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ + ); + }); + + it('should not carry a route — the RUM envelope already stamps the page', () => { + service.track('filter_changed'); + + expect(lastEvent()).not.toHaveProperty('route'); + }); + + it('should ignore an empty event name', () => { + service.track(''); + expect(sink.events).toHaveLength(0); + }); + + it('should redact sensitive metadata', () => { + service.track('sign_in_submitted', { + password: 'hunter2', + method: 'sso' + }); + + expect(lastEvent().metadata).toEqual({ + password: CPS_REDACTED, + method: 'sso' + }); + }); + + it('should drop nested objects rather than serializing them', () => { + service.track('row_selected', { + customer: { id: 1, email: 'a@b.c' }, + rowIndex: 4 + } as never); + + expect(lastEvent().metadata).toEqual({ rowIndex: 4 }); + }); + }); + + describe('withBiEvents({ redact: false })', () => { + it('should skip configurable PII scrubbing but keep the built-in credential denylist', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withBiEvents({ redact: false }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const unredactedService = TestBed.inject(CpsBiTelemetryService); + const unredactedSink = TestBed.inject(RecordingSink); + + unredactedService.track('sign_in_submitted', { + password: 'hunter2', + method: 'sso' + }); + + const [event] = unredactedSink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi); + expect((event.payload as { metadata: unknown }).metadata).toEqual({ + password: CPS_REDACTED, + method: 'sso' + }); + }); + }); + + describe('scenario correlation', () => { + it('should attach the supplied scenario id', () => { + const scenarioTelemetry = TestBed.inject(CpsScenarioTelemetryService); + const scenario = scenarioTelemetry.start({ name: 'export-data' }); + + service.track( + 'export_clicked', + { exportType: 'csv' }, + { scenarioId: scenario.id, feature: 'customers' } + ); + + expect(lastEvent()).toMatchObject({ + scenarioId: scenario.id, + feature: 'customers' + }); + }); + + it('should length-cap feature, the same as any other string field', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withRedaction({ maxStringLength: 4 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const capped = TestBed.inject(CpsBiTelemetryService); + const cappedSink = TestBed.inject(RecordingSink); + + capped.track('export_clicked', undefined, { feature: 'customers' }); + + const [event] = cappedSink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi); + expect((event.payload as { feature: string }).feature).toBe('cust…'); + }); + + it('should leave the scenario id absent when there is no scenario', () => { + service.track('export_clicked'); + expect(lastEvent().scenarioId).toBeUndefined(); + }); + }); + + describe('deduplication', () => { + it('should collapse an identical event fired twice in quick succession', () => { + service.track('export_clicked'); + service.track('export_clicked'); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(1); + }); + + it('should not collapse different event names', () => { + service.track('export_clicked'); + service.track('modal_opened'); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(2); + }); + + it('should not collapse the same event across different scenarios', () => { + service.track('export_clicked', undefined, { scenarioId: 'a' }); + service.track('export_clicked', undefined, { scenarioId: 'b' }); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(2); + }); + + it('should not collapse the same event across different features', () => { + service.track('export_clicked', undefined, { feature: 'customers' }); + service.track('export_clicked', undefined, { feature: 'invoices' }); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(2); + }); + + it('should not collapse the same event across different event types', () => { + service.track('export_clicked', undefined, { eventType: 'com.cps.a' }); + service.track('export_clicked', undefined, { eventType: 'com.cps.b' }); + + expect(sink.events).toHaveLength(2); + }); + + it('should allow the event again once the window has passed', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(1_000_000); + service.track('export_clicked'); + + nowSpy.mockReturnValue(1_000_000 + 500); + service.track('export_clicked'); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(2); + }); + + it('should read the dedup window from CPS_BI_TELEMETRY_CONFIG, not a hardcoded value', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withBiEvents({ dedupWindowMs: 5_000 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const configured = TestBed.inject(CpsBiTelemetryService); + const configuredSink = TestBed.inject(RecordingSink); + + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(0); + configured.track('export_clicked'); + + nowSpy.mockReturnValue(1_000); + configured.track('export_clicked'); + + expect(configuredSink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength( + 1 + ); + }); + + it('should not collapse events with different metadata under the same name', () => { + service.track('theme_option_changed', { dimension: 'color' }); + service.track('theme_option_changed', { dimension: 'radius' }); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(2); + }); + + it('should still collapse identical metadata built in a different key order', () => { + service.track('theme_option_changed', { dimension: 'color', value: 'x' }); + service.track('theme_option_changed', { value: 'x', dimension: 'color' }); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(1); + }); + + it('should build the dedup key from redacted metadata, not the raw values', () => { + service.track('login_attempted', { password: 'hunter2' }); + service.track('login_attempted', { password: 'letmein' }); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(1); + }); + + it('should not collapse metadata that only collides under naive string joining', () => { + service.track('order_placed', { a: '1&b=2' } as never); + service.track('order_placed', { a: '1', b: '2' } as never); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(2); + }); + + it('should not collapse two events whose eventType/feature only collide under naive delimiter joining', () => { + service.track('checkout', undefined, { eventType: 'x|y', feature: 'z' }); + service.track('checkout', undefined, { eventType: 'x', feature: 'y|z' }); + + expect(sink.events).toHaveLength(2); + }); + + it('should sweep stale entries once the key cap is reached, freeing them for reuse', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withBiEvents({ dedupWindowMs: 50, dedupMaxKeys: 2 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const capped = TestBed.inject(CpsBiTelemetryService); + const cappedSink = TestBed.inject(RecordingSink); + const nowSpy = jest.spyOn(performance, 'now'); + + nowSpy.mockReturnValue(0); + capped.track('event_a'); + capped.track('event_b'); + + nowSpy.mockReturnValue(100); + capped.track('event_c'); + capped.track('event_a'); + capped.track('event_b'); + + expect(cappedSink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(5); + }); + + it('should evict the oldest key once the cap is reached even with nothing stale to sweep', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withBiEvents({ dedupWindowMs: 100_000, dedupMaxKeys: 2 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const capped = TestBed.inject(CpsBiTelemetryService); + const cappedSink = TestBed.inject(RecordingSink); + const nowSpy = jest.spyOn(performance, 'now'); + + nowSpy.mockReturnValue(0); + capped.track('key_a'); + nowSpy.mockReturnValue(1); + capped.track('key_b'); + + nowSpy.mockReturnValue(2); + capped.track('key_c'); + + nowSpy.mockReturnValue(3); + capped.track('key_a'); + + expect(cappedSink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(4); + }); + + it('should not evict a just-refreshed key over a genuinely older one, purely because Map.set left it at its original insertion position', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withBiEvents({ dedupWindowMs: 1_000, dedupMaxKeys: 3 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const capped = TestBed.inject(CpsBiTelemetryService); + const cappedSink = TestBed.inject(RecordingSink); + const nowSpy = jest.spyOn(performance, 'now'); + + nowSpy.mockReturnValue(0); + capped.track('key_c'); + + nowSpy.mockReturnValue(500); + capped.track('key_d'); + + nowSpy.mockReturnValue(1_100); + capped.track('key_c'); + + nowSpy.mockReturnValue(1_150); + capped.track('key_e'); + + nowSpy.mockReturnValue(1_160); + capped.track('key_f'); + + nowSpy.mockReturnValue(1_170); + capped.track('key_c'); + + const keyCEvents = cappedSink + .ofType(CPS_TELEMETRY_EVENT_TYPE.bi) + .filter( + (e) => (e.payload as { eventName: string }).eventName === 'key_c' + ); + expect(keyCEvents).toHaveLength(2); + }); + + it('should not evict a frequently-duplicated ("hot") key over one that was only ever touched once, purely because duplicate hits never move it in Map order', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withBiEvents({ dedupWindowMs: 1_000, dedupMaxKeys: 2 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const capped = TestBed.inject(CpsBiTelemetryService); + const cappedSink = TestBed.inject(RecordingSink); + const nowSpy = jest.spyOn(performance, 'now'); + + nowSpy.mockReturnValue(0); + capped.track('hot'); + + nowSpy.mockReturnValue(100); + capped.track('cold'); + + nowSpy.mockReturnValue(200); + capped.track('hot'); + + nowSpy.mockReturnValue(300); + capped.track('warm'); + + nowSpy.mockReturnValue(310); + capped.track('hot'); + + const hotEvents = cappedSink + .ofType(CPS_TELEMETRY_EVENT_TYPE.bi) + .filter( + (e) => (e.payload as { eventName: string }).eventName === 'hot' + ); + expect(hotEvents).toHaveLength(1); + }); + + it('should not let a backwards Date.now jump affect dedup timing at all', () => { + const dateSpy = jest.spyOn(Date, 'now'); + dateSpy.mockReturnValue(2_000_000_000); + service.track('export_clicked'); + + dateSpy.mockReturnValue(1_000_000); + service.track('export_clicked'); + + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(1); + dateSpy.mockRestore(); + }); + }); + + describe('debug output', () => { + let consoleLog: jest.SpyInstance; + + beforeEach(() => { + consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('should stay silent by default', () => { + service.track('export_clicked'); + expect(consoleLog).not.toHaveBeenCalled(); + }); + + it.each(['true', '1'])( + 'should log the event when debugBI is "%s"', + (value) => { + localStorage.setItem('debugBI', value); + service.track('export_clicked', { exportType: 'csv' }); + + expect(consoleLog).toHaveBeenCalledWith( + '[test-app][bi] export_clicked -> com.cps.bi', + expect.objectContaining({ eventName: 'export_clicked' }) + ); + } + ); + + it('should log the very object handed to the sink', () => { + localStorage.setItem('debugBI', 'true'); + service.track('export_clicked', { exportType: 'csv' }); + + const [label, logged] = consoleLog.mock.calls[0]; + const sent = sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)[0]; + + expect(logged).toBe(sent.payload); + expect(label).toContain(CPS_TELEMETRY_EVENT_TYPE.bi); + }); + + it('should stay silent for an invalid debugBI value', () => { + localStorage.setItem('debugBI', 'yes'); + service.track('export_clicked'); + expect(consoleLog).not.toHaveBeenCalled(); + }); + + it('should still record the event when the console itself throws', () => { + localStorage.setItem('debugBI', 'true'); + consoleLog.mockImplementation(() => { + throw new Error('console is patched and broken'); + }); + + expect(() => service.track('export_clicked')).not.toThrow(); + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.bi)).toHaveLength(1); + }); + + it('should not log secrets even when debugging is on', () => { + localStorage.setItem('debugBI', 'true'); + service.track('sign_in', { password: 'hunter2' }); + + expect(consoleLog).toHaveBeenCalledWith( + '[test-app][bi] sign_in -> com.cps.bi', + expect.objectContaining({ metadata: { password: CPS_REDACTED } }) + ); + }); + }); + + describe('event namespace', () => { + it('should emit under the default namespace', () => { + service.track('export_clicked'); + expect(sink.events[0].eventType).toBe('com.cps.bi'); + }); + + it('should emit under an application-specific namespace', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'test-app', + environment: 'test', + version: '1.0.0', + eventNamespace: 'com.test-app' + }), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + + const namespaced = TestBed.inject(CpsBiTelemetryService); + const namespacedSink = TestBed.inject(RecordingSink); + namespaced.track('export_clicked'); + + expect(namespacedSink.events[0].eventType).toBe('com.test-app.bi'); + }); + + it('should let one event override the type, for a legacy dashboard', () => { + service.track( + 'click', + { source: 'toolbar' }, + { + eventType: 'com.test-app.click' + } + ); + + expect(sink.events[0].eventType).toBe('com.test-app.click'); + expect(sink.events[0].payload.eventName).toBe('click'); + }); + + it('should fall back to the configured type when the override is empty', () => { + service.track('click', undefined, { eventType: '' }); + expect(sink.events[0].eventType).toBe('com.cps.bi'); + }); + }); + + describe('failure isolation', () => { + let consoleError: jest.SpyInstance; + + beforeEach(() => { + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleError.mockRestore(); + }); + + it('should never let a broken sink reach application code', () => { + configure(ThrowingSink); + expect(() => service.track('export_clicked')).not.toThrow(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('failed'), + expect.any(Error) + ); + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/services/cps-bi-telemetry.service/cps-bi-telemetry.service.ts b/projects/cps-telemetry/src/lib/services/cps-bi-telemetry.service/cps-bi-telemetry.service.ts new file mode 100644 index 000000000..67a62cfe8 --- /dev/null +++ b/projects/cps-telemetry/src/lib/services/cps-bi-telemetry.service/cps-bi-telemetry.service.ts @@ -0,0 +1,207 @@ +import { inject, Injectable } from '@angular/core'; +import { + CPS_REDACT_CONFIG, + CPS_TELEMETRY_IDENTITY +} from '../../config/cps-telemetry-common.config/cps-telemetry-common.config'; +import { CPS_BI_TELEMETRY_CONFIG } from '../../config/cps-bi-telemetry.config/cps-bi-telemetry.config'; +import { + CpsBiEvent, + CpsBiEventDetail, + CpsBiEventName +} from '../../models/cps-bi.models/cps-bi.models'; +import { + cpsEventTypes, + CpsTelemetryMetadata +} from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { CpsTelemetryMonitor } from '../cps-telemetry-monitor.service/cps-telemetry-monitor.service'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { cpsDebugWrite } from '../../utils/cps-debug-flag.util/cps-debug-flag.util'; +import { + cpsRedactConfigFor, + cpsRedactMetadata, + cpsScrubString +} from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; +import { + cpsNow, + cpsSafeVoid +} from '../../utils/cps-telemetry-safe.util/cps-telemetry-safe.util'; + +/** + * Business and UX event tracking — feature adoption, interaction analysis, + * funnel steps. + * + * Unlike scenarios, BI events are discrete: no duration, no lifecycle. Event + * names and attributes come entirely from the application. + * + * Repeated identical events within a short window are collapsed, absorbing + * double-fires from a `click` handler also bound to `keydown`, or from a + * user clicking twice. + * + * Console output is off unless the `debugBI` LocalStorage flag is set: + * + * ```js + * localStorage.setItem('debugBI', 'true'); + * ``` + * + * @example + * ```typescript + * class CustomerTableComponent { + * private biTelemetry = inject(CpsBiTelemetryService); + * + * onExport(format: string) { + * this.biTelemetry.track('export_clicked', { + * exportType: format, + * source: 'customer-table' + * }); + * } + * } + * ``` + * + * @group Services + */ +@Injectable({ providedIn: 'root' }) +export class CpsBiTelemetryService { + private readonly identity = inject(CPS_TELEMETRY_IDENTITY); + private readonly biConfig = inject(CPS_BI_TELEMETRY_CONFIG); + private readonly redact = cpsRedactConfigFor( + inject(CPS_REDACT_CONFIG), + this.biConfig.redact + ); + + private readonly sink = inject(CpsTelemetrySink); + private readonly monitor = inject(CpsTelemetryMonitor); + private readonly eventTypes = cpsEventTypes(this.identity.eventNamespace); + private readonly lastEmittedAt = new Map<string, number>(); + + /** + * Records a business or UX event. + * + * @param eventName the application's own event name, e.g. `export_clicked`, + * as declared in {@link CpsBiEventNames}. Treat it as a metric dimension: + * keep the cardinality low and never interpolate an identifier into it. + * @param metadata flat attributes describing the interaction + * @param detail optional scenario correlation, feature and event-type override + */ + track( + eventName: CpsBiEventName, + metadata?: CpsTelemetryMetadata, + detail?: CpsBiEventDetail + ): void { + cpsSafeVoid('biTelemetry.track', () => { + if (!eventName) { + return; + } + + const redactedFeature = detail?.feature + ? cpsScrubString(detail.feature, this.redact) + : undefined; + const redactedMetadata = cpsRedactMetadata(metadata, this.redact); + + if ( + this.isDuplicate(eventName, redactedMetadata, { + ...detail, + feature: redactedFeature + }) + ) { + return; + } + + const event: CpsBiEvent = { + eventName, + eventTime: new Date().toISOString(), + scenarioId: detail?.scenarioId, + feature: redactedFeature, + metadata: redactedMetadata, + application: this.identity.application + }; + + const eventType = detail?.eventType || this.eventTypes.bi; + + cpsDebugWrite('debugBI', () => + writeToConsole(eventName, eventType, event) + ); + + this.sink.record(eventType, event); + this.monitor.publish({ + kind: 'bi', + eventType, + payload: event, + destination: 'sink', + origin: { forwarded: false } + }); + }); + } + + private isDuplicate( + eventName: string, + metadata: CpsTelemetryMetadata | undefined, + detail?: CpsBiEventDetail + ): boolean { + const key = JSON.stringify([ + eventName, + detail?.scenarioId ?? '', + detail?.eventType ?? '', + detail?.feature ?? '', + this.metadataKey(metadata) + ]); + const now = cpsNow(); + const last = this.lastEmittedAt.get(key); + + if (last !== undefined && now - last < this.biConfig.dedupWindowMs) { + this.lastEmittedAt.delete(key); + this.lastEmittedAt.set(key, last); + return true; + } + + if (this.lastEmittedAt.size >= this.biConfig.dedupMaxKeys) { + for (const [staleKey, at] of this.lastEmittedAt) { + if (now - at >= this.biConfig.dedupWindowMs) { + this.lastEmittedAt.delete(staleKey); + } + } + + if (this.lastEmittedAt.size >= this.biConfig.dedupMaxKeys) { + const oldestKey = this.lastEmittedAt.keys().next().value; + if (oldestKey !== undefined) { + this.lastEmittedAt.delete(oldestKey); + } + } + } + + this.lastEmittedAt.delete(key); + this.lastEmittedAt.set(key, now); + return false; + } + + /** + * A stable string encoding a flat metadata object's content, for use in + * the dedup key — otherwise two same-named events with different metadata + * would collide and the second would be silently dropped. Keys are sorted + * so property order doesn't affect the result. Callers pass already- + * redacted metadata — a caller's raw values must never end up as a `Map` + * key living in this root service's memory for the page lifetime. + */ + private metadataKey(metadata: CpsTelemetryMetadata | undefined): string { + if (!metadata) { + return ''; + } + return JSON.stringify( + Object.keys(metadata) + .sort() + .map((k) => [k, metadata[k]]) + ); + } +} + +/** + * Prefixed with the application - in a composed + * page every realm writes to the one console. + */ +function writeToConsole( + eventName: string, + eventType: string, + event: CpsBiEvent +): void { + // eslint-disable-next-line no-console + console.log(`[${event.application}][bi] ${eventName} -> ${eventType}`, event); +} diff --git a/projects/cps-telemetry/src/lib/services/cps-logger.service/cps-logger.service.spec.ts b/projects/cps-telemetry/src/lib/services/cps-logger.service/cps-logger.service.spec.ts new file mode 100644 index 000000000..eda48b226 --- /dev/null +++ b/projects/cps-telemetry/src/lib/services/cps-logger.service/cps-logger.service.spec.ts @@ -0,0 +1,871 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CpsLogConfig } from '../../config/cps-log.config/cps-log.config'; +import { + provideCpsTelemetry, + withLogging, + withRedaction +} from '../../providers/cps-telemetry-common.providers/cps-telemetry-common.providers'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { + CPS_LOG_API_PROVIDER, + CpsLogApiProvider, + CpsLogQuery +} from '../../providers/cps-log-api.provider/cps-log-api.provider'; +import * as cpsTelemetryRedactUtil from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; +import { CPS_REDACTED } from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; +import { CpsLoggerService } from './cps-logger.service'; +import { + CpsTelemetryError, + CpsTelemetryMetadata +} from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { + CpsLogDetail, + CpsLogger, + CpsLogRecord +} from '../../models/cps-log.models/cps-log.models'; + +/** Captures what the library emitted, so a test can assert on it. */ +@Injectable() +class RecordingSink extends CpsTelemetrySink { + readonly events: { + eventType: string; + payload: Record<string, unknown>; + metadata?: CpsTelemetryMetadata; + }[] = []; + + readonly errors: CpsTelemetryError[] = []; + readonly flushes: boolean[] = []; + userId?: string; + sessionId: string | undefined = 'test-session'; + + record( + eventType: string, + payload: object, + metadata?: CpsTelemetryMetadata + ): void { + this.events.push({ + eventType, + payload: payload as Record<string, unknown>, + metadata + }); + } + + recordError(error: CpsTelemetryError): void { + this.errors.push(error); + } + + getSessionId(): string | undefined { + return this.sessionId; + } + + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + getUserId(): string | undefined { + return this.userId; + } + + flush(beacon = false): void { + this.flushes.push(beacon); + } + + ofType(eventType: string) { + return this.events.filter((event) => event.eventType === eventType); + } +} + +/** Fails on every call, to prove telemetry cannot break the caller. */ +@Injectable() +class ThrowingSink extends CpsTelemetrySink { + record(): never { + throw new Error('sink is broken'); + } + + recordError(): never { + throw new Error('sink is broken'); + } + + getSessionId(): never { + throw new Error('sink is broken'); + } + + setUserId(): never { + throw new Error('sink is broken'); + } + + getUserId(): never { + throw new Error('sink is broken'); + } + + flush(): never { + throw new Error('sink is broken'); + } +} + +/** Keeps every record, so a test can assert on what was shipped. */ +@Injectable() +class RecordingLogApi implements CpsLogApiProvider { + readonly records: CpsLogRecord[] = []; + flushCount = 0; + + send(record: CpsLogRecord): void { + this.records.push(record); + } + + query(filter: CpsLogQuery): Promise<CpsLogRecord[]> { + let found = this.records; + if (filter.correlationId) { + found = found.filter((r) => r.correlationId === filter.correlationId); + } + if (filter.logger) { + found = found.filter((r) => r.logger === filter.logger); + } + if (filter.limit !== undefined) { + found = found.slice(0, filter.limit); + } + return Promise.resolve(found); + } + + flush(): void { + this.flushCount++; + } +} + +/** A provider implementing no `flush` at all, the common case. */ +@Injectable() +class NoFlushLogApi implements CpsLogApiProvider { + send(): void {} + + query(): Promise<CpsLogRecord[]> { + return Promise.resolve([]); + } +} + +/** Fails on every call, to prove logging cannot break the caller. */ +@Injectable() +class ThrowingLogApi implements CpsLogApiProvider { + send(): never { + throw new Error('log backend is down'); + } + + query(): Promise<CpsLogRecord[]> { + return Promise.reject(new Error('log backend is down')); + } +} + +describe('CpsLoggerService', () => { + /** The service itself — for `getLogger` and `query`. */ + let service: CpsLoggerService; + /** What most tests exercise: a named logger, the only way records are written. */ + let logger: CpsLogger; + let transport: RecordingLogApi; + let sink: RecordingSink; + + function configure(logsOverrides?: Partial<CpsLogConfig>): void { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withLogging(logsOverrides) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + service = TestBed.inject(CpsLoggerService); + logger = service.getLogger('test'); + transport = TestBed.inject(RecordingLogApi); + sink = TestBed.inject(RecordingSink); + } + + beforeEach(() => { + localStorage.clear(); + configure(); + }); + + afterEach(() => { + localStorage.clear(); + jest.restoreAllMocks(); + }); + + describe('levels', () => { + it.each(['log', 'warn', 'error'] as const)( + 'should send a %s record to the transport', + (level) => { + logger[level]('a message'); + + const records = transport.records; + expect(records).toHaveLength(1); + expect(records[0].level).toBe(level); + expect(records[0].message).toBe('a message'); + } + ); + + it('should discard records below the configured minimum level', () => { + configure({ minLevel: 'warn' }); + + logger.log('dropped'); + logger.warn('kept'); + logger.error('kept too'); + + expect(transport.records.map((r) => r.level)).toEqual(['warn', 'error']); + }); + }); + + describe('record shape', () => { + it('should stamp the ambient application context onto every record', () => { + logger.log('hello'); + + expect(transport.records[0]).toMatchObject({ + application: 'test-app', + environment: 'test', + version: '1.0.0', + sessionId: 'test-session' + }); + }); + + it('should take the user id from the sink, not from a copy of its own', () => { + expect(transport.records).toHaveLength(0); + sink.setUserId('user-42'); + logger.log('after sign-in'); + + expect(transport.records[0].userId).toBe('user-42'); + }); + + it('should stop attributing records after sign-out', () => { + sink.setUserId('user-42'); + sink.setUserId(undefined); + logger.log('after sign-out'); + + const record = transport.records[0]; + expect(record).toHaveProperty('userId', undefined); + }); + + it('should carry an ISO-8601 timestamp', () => { + logger.log('hello'); + expect(transport.records[0].timestamp).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ + ); + }); + + it('should record context, metadata and correlation id when supplied', () => { + logger.warn('careful', { + context: 'CustomerService', + metadata: { attempt: 2 }, + correlationId: 'scenario-1' + }); + + expect(transport.records[0]).toMatchObject({ + context: 'CustomerService', + metadata: { attempt: 2 }, + correlationId: 'scenario-1' + }); + }); + + it('should length-cap context, the same as message', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withRedaction({ maxStringLength: 4 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const capped = TestBed.inject(CpsLoggerService).getLogger('test'); + const cappedTransport = TestBed.inject(RecordingLogApi); + + capped.warn('careful', { context: 'CustomerService' }); + + expect(cappedTransport.records[0].context).toBe('Cust…'); + }); + + it('should require nothing beyond the message', () => { + expect(() => logger.log('bare')).not.toThrow(); + expect(transport.records[0].metadata).toBeUndefined(); + expect(transport.records[0].error).toBeUndefined(); + }); + + it('should normalize an error rather than passing it through raw', () => { + logger.error('failed', { error: new TypeError('boom') }); + + const { error } = transport.records[0]; + expect(error).toMatchObject({ name: 'TypeError', message: 'boom' }); + expect(error).not.toBeInstanceOf(Error); + }); + + it('should redact sensitive metadata before it leaves the browser', () => { + logger.log('sign-in attempt', { + metadata: { password: 'hunter2', username: 'ada' } + }); + + expect(transport.records[0].metadata).toEqual({ + password: CPS_REDACTED, + username: 'ada' + }); + }); + + it('should strip URL query strings from the message', () => { + logger.error('GET https://api.dev/me?access_token=xyz failed'); + expect(transport.records[0].message).toBe( + 'GET https://api.dev/me failed' + ); + }); + + it('should scrub correlationId the same way context is scrubbed, not pass it through unredacted', () => { + logger.log('hello', { + correlationId: 'https://api.dev/trace?access_token=xyz' + }); + + expect(transport.records[0].correlationId).toBe('https://api.dev/trace'); + }); + }); + + describe('withLogging({ redact: false })', () => { + it('should skip configurable PII scrubbing but keep the built-in credential denylist and size caps', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withLogging({ redact: false }), + withRedaction({ maxStringLength: 4 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const unredactedLogger = + TestBed.inject(CpsLoggerService).getLogger('test'); + const unredactedTransport = TestBed.inject(RecordingLogApi); + + unredactedLogger.error('GET https://api.dev/me?access_token=xyz failed', { + metadata: { password: 'hunter2', username: 'ada' } + }); + + expect(unredactedTransport.records[0].message).toBe('GET …'); + expect(unredactedTransport.records[0].metadata).toEqual({ + password: CPS_REDACTED, + username: 'ada' + }); + }); + }); + + describe('console output', () => { + let consoleLog: jest.SpyInstance; + let consoleWarn: jest.SpyInstance; + let consoleError: jest.SpyInstance; + + beforeEach(() => { + consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + it('should stay silent by default', () => { + logger.log('quiet'); + logger.warn('quiet'); + logger.error('quiet'); + + expect(consoleLog).not.toHaveBeenCalled(); + expect(consoleWarn).not.toHaveBeenCalled(); + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('should write to the console when debugLogger is "true"', () => { + localStorage.setItem('debugLogger', 'true'); + logger.log('loud'); + expect(consoleLog).toHaveBeenCalledWith( + '[test-app][test] loud', + expect.objectContaining({ message: 'loud' }) + ); + }); + + it('should log the very record handed to the transport', () => { + localStorage.setItem('debugLogger', 'true'); + logger.log('loud', { metadata: { a: 1 } }); + + const [label, logged] = consoleLog.mock.calls[0]; + + expect(label).toBe('[test-app][test] loud'); + expect(logged).toBe(transport.records[0]); + }); + + it('should still deliver the record when the console itself throws', () => { + localStorage.setItem('debugLogger', 'true'); + consoleLog.mockImplementation(() => { + throw new Error('console is patched and broken'); + }); + + expect(() => logger.log('loud')).not.toThrow(); + expect(transport.records).toHaveLength(1); + expect(transport.records[0]).toMatchObject({ message: 'loud' }); + }); + + it('should write only the named logger when debugLogger names it', () => { + localStorage.setItem('debugLogger', 'checkout'); + service.getLogger('checkout').log('loud'); + service.getLogger('admin').log('quiet'); + logger.log('quiet too'); + + expect(consoleLog).toHaveBeenCalledTimes(1); + expect(consoleLog).toHaveBeenCalledWith( + '[test-app][checkout] loud', + expect.objectContaining({ logger: 'checkout' }) + ); + }); + + it('should accept a comma-separated list of logger names', () => { + localStorage.setItem('debugLogger', 'checkout, admin'); + service.getLogger('checkout').log('a'); + service.getLogger('admin').log('b'); + service.getLogger('reports').log('c'); + + expect(consoleLog).toHaveBeenCalledTimes(2); + }); + + it('should name each logger when several are filtered in at once', () => { + localStorage.setItem('debugLogger', 'checkout, admin'); + service.getLogger('checkout').log('a'); + service.getLogger('admin').log('b'); + + expect(consoleLog.mock.calls.map(([label]: [string]) => label)).toEqual([ + '[test-app][checkout] a', + '[test-app][admin] b' + ]); + }); + + it('should show the logger and the context together, coarse first', () => { + localStorage.setItem('debugLogger', 'true'); + service.getLogger('checkout').log('working', { context: 'Loader' }); + + expect(consoleLog).toHaveBeenCalledWith( + '[test-app][checkout][Loader] working', + expect.objectContaining({ logger: 'checkout', context: 'Loader' }) + ); + }); + + it('should still write every logger when debugLogger is "true"', () => { + localStorage.setItem('debugLogger', 'true'); + service.getLogger('checkout').log('a'); + logger.log('b'); + + expect(consoleLog).toHaveBeenCalledTimes(2); + }); + + it('should write to the console when debugLogger is "1"', () => { + localStorage.setItem('debugLogger', '1'); + logger.warn('loud'); + expect(consoleWarn).toHaveBeenCalled(); + }); + + it('should stay silent for an invalid debugLogger value', () => { + localStorage.setItem('debugLogger', 'yes'); + logger.log('quiet'); + expect(consoleLog).not.toHaveBeenCalled(); + }); + + it('should use the console method matching the level', () => { + localStorage.setItem('debugLogger', 'true'); + logger.error('bad'); + expect(consoleError).toHaveBeenCalled(); + expect(consoleLog).not.toHaveBeenCalled(); + }); + + it('should include the context and correlation id in the console line', () => { + localStorage.setItem('debugLogger', 'true'); + logger.log('working', { + context: 'Loader', + correlationId: 'abc-123' + }); + expect(consoleLog).toHaveBeenCalledWith( + '[test-app][test][Loader] working (abc-123)', + expect.objectContaining({ correlationId: 'abc-123' }) + ); + }); + + it('should still send to the transport while console output is on', () => { + localStorage.setItem('debugLogger', 'true'); + logger.log('both'); + expect(transport.records).toHaveLength(1); + }); + }); + + describe('named loggers', () => { + it('should stamp the name onto every record', () => { + configure(); + service.getLogger('checkout').log('submitting'); + + expect(transport.records[0].logger).toBe('checkout'); + }); + + it('should keep context free-form alongside the name', () => { + configure(); + service.getLogger('checkout').log('submitting', { + context: 'OrderService' + }); + + expect(transport.records[0]).toMatchObject({ + logger: 'checkout', + context: 'OrderService' + }); + }); + + it('should return the very same logger for a name asked for twice', () => { + // A name is identity, not a label: it addresses one logger the way + // a file name addresses one file. + configure(); + + expect(service.getLogger('checkout')).toBe(service.getLogger('checkout')); + }); + + it('should give different names different loggers', () => { + configure(); + + expect(service.getLogger('checkout')).not.toBe( + service.getLogger('admin') + ); + }); + + it('should keep returning the same logger across many lookups', () => { + configure(); + const first = service.getLogger('checkout'); + service.getLogger('admin'); + service.getLogger('reports'); + + expect(service.getLogger('checkout')).toBe(first); + }); + + it('should ignore a stray logger key in per-call detail', () => { + // `CpsLogDetail` has no `logger` field, so this cannot be written in + // TypeScript — the cast proves the runtime honours the logger's own + // name too, rather than relying on the type alone. + configure(); + service + .getLogger('checkout') + .log('elsewhere', { logger: 'admin' } as unknown as CpsLogDetail); + + expect(transport.records[0].logger).toBe('checkout'); + }); + + it('should name every record, there being no unnamed way in', () => { + // getLogger is the service's only writing entry point, so a record + // without a `logger` cannot be produced — the four things that key + // off the name (levels, the debugLogger filter, query({ logger }) + // and the console prefix) always have something to target. + configure(); + logger.log('always named'); + service.getLogger('checkout').log('named too'); + + expect(transport.records.map((r) => r.logger)).toEqual([ + 'test', + 'checkout' + ]); + }); + }); + + describe('per-logger levels', () => { + it('should let one logger run below the global floor', () => { + configure({ minLevel: 'warn', levels: { checkout: 'log' } }); + + service.getLogger('checkout').log('kept'); + service.getLogger('admin').log('dropped'); + logger.log('dropped too'); + + expect(transport.records.map((r) => r.message)).toEqual(['kept']); + }); + + it('should let one logger be quieter than the global floor', () => { + configure({ minLevel: 'log', levels: { checkout: 'error' } }); + + service.getLogger('checkout').warn('dropped'); + service.getLogger('checkout').error('kept'); + service.getLogger('admin').warn('kept too'); + + expect(transport.records.map((r) => r.message)).toEqual([ + 'kept', + 'kept too' + ]); + }); + + it('should fall back to the global floor for an unlisted logger', () => { + configure({ minLevel: 'error', levels: { checkout: 'log' } }); + + service.getLogger('admin').warn('dropped'); + + expect(transport.records).toHaveLength(0); + }); + }); + + describe('query', () => { + it('should read records back from the backend', async () => { + configure(); + service.getLogger('checkout').log('first'); + logger.log('second'); + + const all = await service.query(); + + expect(all.map((r) => r.message)).toEqual(['first', 'second']); + }); + + it('should pass the filter through to the backend', async () => { + configure(); + const scenarioId = 'abc-123'; + logger.log('mine', { correlationId: scenarioId }); + logger.log('someone else'); + + const found = await service.query({ correlationId: scenarioId }); + + expect(found.map((r) => r.message)).toEqual(['mine']); + }); + + it('should resolve to an empty array when the backend rejects', async () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'test-app', + environment: 'test', + version: '1.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useClass: ThrowingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + + await expect(TestBed.inject(CpsLoggerService).query()).resolves.toEqual( + [] + ); + }); + }); + + describe('requires a log API provider', () => { + it('should fail construction with no CPS_LOG_API_PROVIDER bound', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'test-app', + environment: 'test', + version: '1.0.0' + }), + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + + expect(() => TestBed.inject(CpsLoggerService)).toThrow(); + }); + }); + + describe('RUM error mirroring', () => { + it('should not mirror errors to the sink by default', () => { + logger.error('failed', { error: new Error('boom') }); + expect(sink.errors).toHaveLength(0); + }); + + it('should mirror errors to the sink when enabled', () => { + configure({ mirrorErrorsToRum: true }); + logger.error('failed', { error: new Error('boom') }); + + expect(sink.errors).toEqual([ + expect.objectContaining({ name: 'Error', message: 'boom' }) + ]); + }); + + it('should mirror a message-only error using the message', () => { + configure({ mirrorErrorsToRum: true }); + logger.error('no error object supplied'); + + expect(sink.errors[0].message).toBe('no error object supplied'); + }); + + it('should not mirror non-error levels', () => { + configure({ mirrorErrorsToRum: true }); + logger.warn('careful', { error: new Error('boom') }); + expect(sink.errors).toHaveLength(0); + }); + }); + + describe('without a sink configured', () => { + function configureWithoutSink(logsOverrides?: Partial<CpsLogConfig>): void { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withLogging(logsOverrides) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi } + ] + }); + service = TestBed.inject(CpsLoggerService); + logger = service.getLogger('test'); + transport = TestBed.inject(RecordingLogApi); + } + + it('should not throw on any level with no sink provided', () => { + configureWithoutSink(); + expect(() => { + logger.log('a'); + logger.warn('b'); + logger.error('c'); + }).not.toThrow(); + }); + + it('should still deliver the record to the log backend with no sink', () => { + configureWithoutSink(); + logger.log('still delivered'); + expect(transport.records).toHaveLength(1); + }); + + it('should omit sessionId and userId entirely with no sink, not merely leave them undefined', () => { + configureWithoutSink(); + logger.log('no identity source'); + + const record = transport.records[0]; + expect(record).not.toHaveProperty('sessionId'); + expect(record).not.toHaveProperty('userId'); + }); + + it('should not throw when mirrorErrorsToRum is on but no sink is provided', () => { + configureWithoutSink({ mirrorErrorsToRum: true }); + expect(() => + logger.error('failed', { error: new Error('boom') }) + ).not.toThrow(); + }); + + it('should skip building the mirrored error entirely with no sink to send it to', () => { + const normalizeSpy = jest.spyOn( + cpsTelemetryRedactUtil, + 'cpsNormalizeError' + ); + configureWithoutSink({ mirrorErrorsToRum: true }); + + logger.error('message-only error'); + + expect(normalizeSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('failure isolation', () => { + let consoleError: jest.SpyInstance; + + beforeEach(() => { + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleError.mockRestore(); + }); + + it('should not propagate a throwing transport', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'test-app', + environment: 'test', + version: '1.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useClass: ThrowingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + + const isolated = TestBed.inject(CpsLoggerService).getLogger('test'); + expect(() => isolated.log('still fine')).not.toThrow(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('logger.deliver failed'), + expect.any(Error) + ); + }); + + it('should not propagate a throwing sink while mirroring errors', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withLogging({ mirrorErrorsToRum: true }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + ThrowingSink, + { provide: CpsTelemetrySink, useExisting: ThrowingSink } + ] + }); + + const isolated = TestBed.inject(CpsLoggerService).getLogger('test'); + expect(() => isolated.error('still fine')).not.toThrow(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('failed'), + expect.any(Error) + ); + }); + }); + + describe('page unload', () => { + it('should flush the provider on pagehide', () => { + window.dispatchEvent(new Event('pagehide')); + expect(transport.flushCount).toBe(1); + }); + + it('should flush the provider when the page goes hidden', () => { + jest.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + expect(transport.flushCount).toBe(1); + }); + + it('should not flush merely on becoming visible again', () => { + jest.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); + document.dispatchEvent(new Event('visibilitychange')); + expect(transport.flushCount).toBe(0); + }); + + it('should flush the provider on destroy', () => { + service.ngOnDestroy(); + expect(transport.flushCount).toBe(1); + }); + + it('should stop listening once destroyed', () => { + service.ngOnDestroy(); + transport.flushCount = 0; + + window.dispatchEvent(new Event('pagehide')); + + expect(transport.flushCount).toBe(0); + }); + + it('should tolerate a provider that implements no flush', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry({ + application: 'test-app', + environment: 'test', + version: '1.0.0' + }), + { provide: CPS_LOG_API_PROVIDER, useClass: NoFlushLogApi } + ] + }); + TestBed.inject(CpsLoggerService); + + expect(() => window.dispatchEvent(new Event('pagehide'))).not.toThrow(); + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/services/cps-logger.service/cps-logger.service.ts b/projects/cps-telemetry/src/lib/services/cps-logger.service/cps-logger.service.ts new file mode 100644 index 000000000..c2fac7f7a --- /dev/null +++ b/projects/cps-telemetry/src/lib/services/cps-logger.service/cps-logger.service.ts @@ -0,0 +1,340 @@ +import { DOCUMENT } from '@angular/common'; +import { inject, Injectable, OnDestroy } from '@angular/core'; +import { + CPS_REDACT_CONFIG, + CPS_TELEMETRY_IDENTITY +} from '../../config/cps-telemetry-common.config/cps-telemetry-common.config'; +import { CPS_LOG_CONFIG } from '../../config/cps-log.config/cps-log.config'; +import { + CPS_LOG_LEVEL_ORDER, + CpsLogger, + CpsLoggerName, + CpsLogDetail, + CpsLogLevel, + CpsLogRecord +} from '../../models/cps-log.models/cps-log.models'; +import { CpsTelemetryMonitor } from '../cps-telemetry-monitor.service/cps-telemetry-monitor.service'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { + CPS_LOG_API_PROVIDER, + CpsLogQuery +} from '../../providers/cps-log-api.provider/cps-log-api.provider'; +import { cpsDebugWrite } from '../../utils/cps-debug-flag.util/cps-debug-flag.util'; +import { + cpsNormalizeError, + cpsRedactConfigFor, + cpsRedactMetadata, + cpsScrubString +} from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; +import { + cpsIsBrowser, + cpsSafe, + cpsSafeVoid, + cpsSafeVoidMaybeAsync +} from '../../utils/cps-telemetry-safe.util/cps-telemetry-safe.util'; + +/** + * Structured application logging. + * + * Log records go to the application's {@link CpsLogApiProvider}, **not** to + * AWS RUM — RUM is a sampled, session-capped analytics stream, while logs + * need full fidelity and their own retention. + * + * Console output is off unless the `debugLogger` LocalStorage flag is set, in + * any environment: + * + * ```js + * localStorage.setItem('debugLogger', 'true'); + * ``` + * + * Every record carries a logger name, so the service itself writes + * nothing — {@link getLogger} hands back the {@link CpsLogger} you call. + * + * @example + * ```typescript + * private readonly logger = inject(CpsLoggerService).getLogger('checkout'); + * + * this.logger.error('Failed to load customer data', { + * error, + * correlationId: scenario.id + * }); + * ``` + * + * @group Services + */ +@Injectable({ providedIn: 'root' }) +export class CpsLoggerService implements OnDestroy { + private readonly identity = inject(CPS_TELEMETRY_IDENTITY); + private readonly logsConfig = inject(CPS_LOG_CONFIG); + private readonly redact = cpsRedactConfigFor( + inject(CPS_REDACT_CONFIG), + this.logsConfig.redact + ); + + /** Enrichment only (`sessionId`/`userId`, optional RUM mirroring) — see DESIGN.md §10. */ + private readonly sink = inject(CpsTelemetrySink, { optional: true }); + private readonly apiProvider = inject(CPS_LOG_API_PROVIDER); + private readonly monitor = inject(CpsTelemetryMonitor); + private readonly document = inject(DOCUMENT); + private readonly isBrowser = cpsIsBrowser(); + + private readonly onPageHide = () => this.flushProvider(); + private readonly onVisibilityChange = () => { + if (this.document.visibilityState === 'hidden') { + this.flushProvider(); + } + }; + + /** + * One logger per name, so {@link getLogger} is idempotent. + * + * Unbounded growth is not a concern the way it is for active scenarios: + * names come from the closed {@link CpsLoggerNames} vocabulary, so this + * map is bounded by what the application declared at build time. + */ + private readonly loggers = new Map<CpsLoggerName, CpsLogger>(); + + constructor() { + if (this.isBrowser) { + this.document.defaultView?.addEventListener('pagehide', this.onPageHide); + + this.document.addEventListener( + 'visibilitychange', + this.onVisibilityChange + ); + } + } + + /** @inheritdoc */ + ngOnDestroy(): void { + if (this.isBrowser) { + this.document.defaultView?.removeEventListener( + 'pagehide', + this.onPageHide + ); + this.document.removeEventListener( + 'visibilitychange', + this.onVisibilityChange + ); + } + this.flushProvider(); + } + + /** + * Reads records back from the application's log backend. Useful for + * pulling one journey together — every line written during a scenario + * shares its id as the correlation id: + * + * @example + * ```typescript + * const lines = await this.logger.query({ correlationId: scenario.id }); + * ``` + * + * Fail-open: a provider that throws or rejects resolves to `[]`. + * + * @param filter narrows what is returned + * @returns the matching records, or `[]` when none can be read + */ + async query(filter: CpsLogQuery = {}): Promise<CpsLogRecord[]> { + const pending = cpsSafe( + 'logger.query', + () => this.apiProvider.query(filter), + undefined + ); + + return (await pending?.catch(() => undefined)) ?? []; + } + + /** + * Returns the named logger for one part of the application — the only + * way to write a log record. + * + * The name lands on every record as `logger`, and four things key off + * it: {@link CpsLogConfig.levels} per-logger severity floors, the + * `debugLogger` flag's comma-separated filter, `query({ logger })` + * against the log backend, and the console line's prefix. A record + * without one is reachable by none of them, which is why the service + * exposes no unnamed `log`/`warn`/`error` of its own. + * + * Declare the name in {@link CpsLoggerNames} first, and bind it once as + * a field: + * + * @example + * ```typescript + * class CheckoutService { + * private readonly logger = inject(CpsLoggerService).getLogger('checkout'); + * + * submit() { + * this.logger.log('Submitting order'); + * } + * } + * ``` + * + * The name is identity, not a label: asking twice returns the very same + * logger, the way a file name always refers to one file. + * + * @param name the logger name, declared in {@link CpsLoggerNames} + * @returns the logger for that name, created once and reused + */ + getLogger(name: CpsLoggerName): CpsLogger { + const cached = cpsSafe( + 'logger.getLogger', + () => this.loggers.get(name), + undefined + ); + if (cached) { + return cached; + } + + const logger = this.createLogger(name); + cpsSafeVoid('logger.register', () => this.loggers.set(name, logger)); + return logger; + } + + /** + * Builds the logger for one name. + * + * Private, and reached only through {@link getLogger}, so every logger + * handed out is registered and named. The name is captured here rather + * than read from each call's detail, which is why a record's `logger` + * cannot disagree with the logger that wrote it. + * + * @param name the logger name stamped onto every record it writes + * @returns a logger bound to that name + */ + private createLogger(name: CpsLoggerName): CpsLogger { + return { + log: (message, detail) => this.emit('log', name, message, detail), + warn: (message, detail) => this.emit('warn', name, message, detail), + error: (message, detail) => this.emit('error', name, message, detail) + }; + } + + /** Severity floor for one logger — its own override, or the global one. */ + private minLevelFor(logger: CpsLoggerName): CpsLogLevel { + const { levels, minLevel } = this.logsConfig; + return levels?.[logger] ?? minLevel; + } + + private emit( + level: CpsLogLevel, + logger: CpsLoggerName, + message: string, + detail?: CpsLogDetail + ): void { + cpsSafeVoid(`logger.${level}`, () => { + if ( + CPS_LOG_LEVEL_ORDER[level] < + CPS_LOG_LEVEL_ORDER[this.minLevelFor(logger)] + ) { + return; + } + + const record = this.buildRecord(level, logger, message, detail); + + cpsDebugWrite('debugLogger', () => writeToConsole(record), record.logger); + + const sequence = this.deliver(record); + + if (level === 'error' && this.logsConfig.mirrorErrorsToRum && this.sink) { + const mirrored = + record.error ?? + cpsNormalizeError(new Error(record.message), this.redact); + if (mirrored) { + this.sink.recordError(mirrored); + this.monitor.publish({ + kind: 'error', + payload: mirrored, + relatedSequence: sequence, + destination: 'sink', + origin: { forwarded: false } + }); + } + } + }); + } + + /** + * Guards against a throwing or secretly-async, rejecting provider, and + * tells the monitor once the record has been handed over. + * + * @returns the monitor's sequence number for the record, when observed + */ + private deliver(record: CpsLogRecord): number | undefined { + let sequence: number | undefined; + cpsSafeVoidMaybeAsync('logger.deliver', () => { + const pending = this.apiProvider.send(record); + // After send, so a provider that throws synchronously is not reported + // as handed over. An asynchronous rejection can't be seen here. + sequence = this.monitor.publish({ + kind: 'log', + payload: record, + destination: 'log-provider', + origin: { forwarded: false } + }); + return pending; + }); + return sequence; + } + + /** Gives the provider its chance to ship whatever it has queued itself. */ + private flushProvider(): void { + cpsSafeVoidMaybeAsync('logger.providerFlush', () => + this.apiProvider.flush?.() + ); + } + + private buildRecord( + level: CpsLogLevel, + logger: CpsLoggerName, + message: string, + detail?: CpsLogDetail + ): CpsLogRecord { + const redact = this.redact; + const sink = this.sink; + const identity = this.identity; + + return { + timestamp: new Date().toISOString(), + level, + message: cpsScrubString(String(message ?? ''), redact), + logger, + context: detail?.context + ? cpsScrubString(detail.context, redact) + : undefined, + metadata: cpsRedactMetadata(detail?.metadata, redact), + error: cpsNormalizeError(detail?.error, redact), + correlationId: detail?.correlationId + ? cpsScrubString(detail.correlationId, redact) + : undefined, + application: identity.application, + environment: identity.environment, + version: identity.version, + ...(sink && { + userId: cpsSafe('getUserId', () => sink.getUserId(), undefined), + sessionId: cpsSafe('getSessionId', () => sink.getSessionId(), undefined) + }) + }; + } +} + +/** + * Prints the record exactly as the transport receives it. + * + * Prefixed with the application - in a composed + * page every realm writes to the one console - then the logger name and + * the context, each when present. + */ +function writeToConsole(record: CpsLogRecord): void { + const scope = [record.logger, record.context] + .filter(Boolean) + .map((part) => `[${part}]`) + .join(''); + const suffix = record.correlationId ? ` (${record.correlationId})` : ''; + + // eslint-disable-next-line no-console + console[record.level]( + `[${record.application}]${scope} ${record.message}${suffix}`, + record + ); +} diff --git a/projects/cps-telemetry/src/lib/services/cps-scenario-telemetry.service/cps-scenario-telemetry.service.spec.ts b/projects/cps-telemetry/src/lib/services/cps-scenario-telemetry.service/cps-scenario-telemetry.service.spec.ts new file mode 100644 index 000000000..6fb7e8eb2 --- /dev/null +++ b/projects/cps-telemetry/src/lib/services/cps-scenario-telemetry.service/cps-scenario-telemetry.service.spec.ts @@ -0,0 +1,2192 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CpsScenarioTelemetryConfig } from '../../config/cps-scenario-telemetry.config/cps-scenario-telemetry.config'; +import { + provideCpsTelemetry, + withRedaction, + withScenarios +} from '../../providers/cps-telemetry-common.providers/cps-telemetry-common.providers'; +import { + CpsScenarioRecord, + CpsScenarioStatus +} from '../../models/cps-scenario.models/cps-scenario.models'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { + CPS_LOG_API_PROVIDER, + CpsLogApiProvider, + CpsLogQuery +} from '../../providers/cps-log-api.provider/cps-log-api.provider'; +import { CPS_REDACTED } from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; +import { CpsScenarioTelemetryService } from './cps-scenario-telemetry.service'; +import { + CPS_TELEMETRY_EVENT_TYPE, + CpsTelemetryError, + CpsTelemetryMetadata +} from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { CpsLogRecord } from '../../models/cps-log.models/cps-log.models'; + +/** Captures what the library emitted, so a test can assert on it. */ +@Injectable() +class RecordingSink extends CpsTelemetrySink { + readonly events: { + eventType: string; + payload: Record<string, unknown>; + metadata?: CpsTelemetryMetadata; + }[] = []; + + readonly errors: CpsTelemetryError[] = []; + readonly flushes: boolean[] = []; + userId?: string; + sessionId: string | undefined = 'test-session'; + + record( + eventType: string, + payload: object, + metadata?: CpsTelemetryMetadata + ): void { + this.events.push({ + eventType, + payload: payload as Record<string, unknown>, + metadata + }); + } + + recordError(error: CpsTelemetryError): void { + this.errors.push(error); + } + + getSessionId(): string | undefined { + return this.sessionId; + } + + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + getUserId(): string | undefined { + return this.userId; + } + + flush(beacon = false): void { + this.flushes.push(beacon); + } + + ofType(eventType: string) { + return this.events.filter((event) => event.eventType === eventType); + } +} + +/** Fails on every call, to prove telemetry cannot break the caller. */ +@Injectable() +class ThrowingSink extends CpsTelemetrySink { + record(): never { + throw new Error('sink is broken'); + } + + recordError(): never { + throw new Error('sink is broken'); + } + + getSessionId(): never { + throw new Error('sink is broken'); + } + + setUserId(): never { + throw new Error('sink is broken'); + } + + getUserId(): never { + throw new Error('sink is broken'); + } + + flush(): never { + throw new Error('sink is broken'); + } +} + +/** Keeps every batch, so a test can assert on what was shipped. */ +@Injectable() +class RecordingLogApi implements CpsLogApiProvider { + readonly records: CpsLogRecord[] = []; + + send(record: CpsLogRecord): void { + this.records.push(record); + } + + query(filter: CpsLogQuery): Promise<CpsLogRecord[]> { + let found = this.records; + if (filter.correlationId) { + found = found.filter((r) => r.correlationId === filter.correlationId); + } + if (filter.logger) { + found = found.filter((r) => r.logger === filter.logger); + } + if (filter.limit !== undefined) { + found = found.slice(0, filter.limit); + } + return Promise.resolve(found); + } +} + +describe('CpsScenarioTelemetryService', () => { + let service: CpsScenarioTelemetryService; + let sink: RecordingSink; + + function configure( + scenarioOverrides?: Partial<CpsScenarioTelemetryConfig>, + eventNamespace?: string + ): void { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { + application: 'test-app', + environment: 'test', + version: '1.0.0', + ...(eventNamespace ? { eventNamespace } : {}) + }, + withScenarios({ + defaultTimeoutMs: 0, + ...scenarioOverrides + }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + service = TestBed.inject(CpsScenarioTelemetryService); + sink = TestBed.inject(RecordingSink); + } + + /** The single packed scenario event, asserted to be the only one emitted. */ + function onlyScenarioRecord(): CpsScenarioRecord { + const events = sink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario); + expect(events).toHaveLength(1); + return events[0].payload as unknown as CpsScenarioRecord; + } + + beforeEach(() => { + localStorage.clear(); + configure(); + }); + + afterEach(() => { + localStorage.clear(); + jest.restoreAllMocks(); + }); + + describe('creation', () => { + it('should return a scenario carrying the requested name', () => { + const scenario = service.start({ name: 'load-customer-data' }); + expect(scenario.name).toBe('load-customer-data'); + expect(scenario.status).toBeUndefined(); + expect(scenario.isSettled).toBe(false); + }); + + it('should report an undefined status on a mid-flight toRecord() snapshot, not a lie', () => { + const scenario = service.start({ name: 'load-customer-data' }); + const snapshot = scenario.toRecord(); + expect(snapshot.status).toBeUndefined(); + expect(snapshot.endTime).toBeUndefined(); + + scenario.complete(); + expect(scenario.toRecord().status).toBe('success'); + }); + + it('should generate a unique id per scenario', () => { + const ids = new Set( + Array.from({ length: 50 }, () => service.start({ name: 's' }).id) + ); + expect(ids.size).toBe(50); + }); + + it('should not emit anything until the scenario settles', () => { + service.start({ name: 'load' }); + expect(sink.events).toHaveLength(0); + }); + + it('should track the scenario as active until it settles', () => { + const scenario = service.start({ name: 'load' }); + expect(service.find(scenario.id)).toBe(scenario); + expect(service.getActive()).toEqual([scenario]); + + scenario.complete(); + + expect(service.find(scenario.id)).toBeUndefined(); + expect(service.getActive()).toEqual([]); + }); + }); + + describe('timing', () => { + const isoPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/; + + it('should record ISO-8601 start and end times', () => { + const before = new Date().toISOString(); + const scenario = service.start({ name: 'load' }); + scenario.complete(); + const after = new Date().toISOString(); + + const record = onlyScenarioRecord(); + expect(record.startTime).toMatch(isoPattern); + expect(record.endTime).toMatch(isoPattern); + expect(record.startTime >= before).toBe(true); + expect(record.endTime! <= after).toBe(true); + expect(record.endTime! >= record.startTime).toBe(true); + }); + + it('should measure a real duration as delta', () => { + jest.useFakeTimers(); + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(1000); + + const scenario = service.start({ name: 'load' }); + nowSpy.mockReturnValue(1250); + scenario.complete(); + + expect(onlyScenarioRecord().delta).toBe(250); + jest.useRealTimers(); + }); + + it('should expose a growing delta value while in progress', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(0); + const scenario = service.start({ name: 'load' }); + + nowSpy.mockReturnValue(500); + expect(scenario.delta).toBe(500); + + nowSpy.mockReturnValue(900); + scenario.complete(); + nowSpy.mockReturnValue(5000); + expect(scenario.delta).toBe(900); + }); + + it('should stamp elapsed as milliseconds since page load, distinct from delta', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(2000); + const scenario = service.start({ name: 'load' }); + + nowSpy.mockReturnValue(2400); + scenario.complete(); + + const record = onlyScenarioRecord(); + expect(record.delta).toBe(400); + expect(record.elapsed).toBe(2400); + }); + }); + + describe('concurrent scenarios', () => { + it('should keep interleaved scenarios completely independent', () => { + const a = service.start({ name: 'scenario-a' }); + const b = service.start({ name: 'scenario-b' }); + + a.step('a1'); + b.step('b1'); + a.step('a2'); + b.step('b2'); + a.step('a3'); + + expect(service.getActive()).toHaveLength(2); + + a.complete(); + b.fail({ error: new Error('b failed') }); + + const events = sink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario); + expect(events).toHaveLength(2); + + const recordA = events[0].payload as unknown as CpsScenarioRecord; + const recordB = events[1].payload as unknown as CpsScenarioRecord; + + expect(recordA.scenarioName).toBe('scenario-a'); + expect(recordA.status).toBe('success'); + expect(recordA.steps.map((s) => s.name)).toEqual([ + 'scenario-start', + 'a1', + 'a2', + 'a3', + 'scenario-end' + ]); + + expect(recordB.scenarioName).toBe('scenario-b'); + expect(recordB.status).toBe('failure'); + expect(recordB.steps.map((s) => s.name)).toEqual([ + 'scenario-start', + 'b1', + 'b2', + 'scenario-end' + ]); + + expect(recordA.scenarioId).not.toBe(recordB.scenarioId); + }); + + it('should support many scenarios of the same name running at once', () => { + const scenarios = Array.from({ length: 5 }, () => + service.start({ name: 'load-widget' }) + ); + scenarios.forEach((s, i) => s.step(`step-${i}`)); + scenarios.forEach((s) => s.complete()); + + const records = sink + .ofType(CPS_TELEMETRY_EVENT_TYPE.scenario) + .map((e) => e.payload as unknown as CpsScenarioRecord); + + expect(records).toHaveLength(5); + expect(new Set(records.map((r) => r.scenarioId)).size).toBe(5); + records.forEach((record, i) => { + expect(record.steps.map((s) => s.name)).toEqual([ + 'scenario-start', + `step-${i}`, + 'scenario-end' + ]); + }); + }); + }); + + describe('findByName', () => { + it('should return every active scenario sharing a name, and none of a different name', () => { + const a1 = service.start({ name: 'load-widget' }); + const b = service.start({ name: 'load-report' }); + const a2 = service.start({ name: 'load-widget' }); + + expect(service.findByName('load-widget')).toEqual([a1, a2]); + expect(service.findByName('load-report')).toEqual([b]); + }); + + it('should exclude a scenario once it has settled', () => { + const a1 = service.start({ name: 'load-widget' }); + const a2 = service.start({ name: 'load-widget' }); + + a1.complete(); + + expect(service.findByName('load-widget')).toEqual([a2]); + }); + + it('should return an empty array when nothing is active under that name', () => { + expect(service.findByName('load-widget')).toEqual([]); + }); + }); + + describe('findByNameAndId', () => { + it('should return the scenario when the id is active and the name matches', () => { + const scenario = service.start({ name: 'load-widget' }); + + expect(service.findByNameAndId('load-widget', scenario.id)).toBe( + scenario + ); + }); + + it('should return undefined when the id is active but under a different name', () => { + const scenario = service.start({ name: 'load-widget' }); + + expect( + service.findByNameAndId('load-report', scenario.id) + ).toBeUndefined(); + }); + + it('should return undefined for an unknown id', () => { + expect( + service.findByNameAndId('load-widget', 'no-such-id') + ).toBeUndefined(); + }); + + it('should return undefined once the scenario has settled', () => { + const scenario = service.start({ name: 'load-widget' }); + scenario.complete(); + + expect( + service.findByNameAndId('load-widget', scenario.id) + ).toBeUndefined(); + }); + }); + + describe('steps', () => { + it('should close the previous step when the next one opens', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(0); + + const scenario = service.start({ name: 'load' }); + nowSpy.mockReturnValue(100); + scenario.step('fetch'); + nowSpy.mockReturnValue(400); + scenario.step('render'); + nowSpy.mockReturnValue(500); + scenario.complete(); + + const [start, fetch, render, end] = onlyScenarioRecord().steps; + expect(start).toMatchObject({ name: 'scenario-start', stepDelta: 0 }); + expect(fetch).toMatchObject({ + name: 'fetch', + startOffset: 100, + endOffset: 400, + stepDelta: 300, + status: 'success' + }); + expect(render).toMatchObject({ + name: 'render', + startOffset: 400, + endOffset: 500, + stepDelta: 100, + status: 'success' + }); + expect(end).toMatchObject({ name: 'scenario-end', stepDelta: 0 }); + }); + + it('should always bookend the real steps with scenario-start and scenario-end', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('fetch'); + scenario.complete(); + + expect(onlyScenarioRecord().steps.map((s) => s.name)).toEqual([ + 'scenario-start', + 'fetch', + 'scenario-end' + ]); + }); + + it('should still have exactly the two boundary markers when no step is ever opened', () => { + const scenario = service.start({ name: 'load' }); + scenario.complete(); + + const steps = onlyScenarioRecord().steps; + expect(steps).toHaveLength(2); + expect(steps.map((s) => s.name)).toEqual([ + 'scenario-start', + 'scenario-end' + ]); + expect(steps[0]).toMatchObject({ + startOffset: 0, + endOffset: 0, + stepDelta: 0, + status: 'success' + }); + expect(steps[1]).toMatchObject({ stepDelta: 0, status: 'success' }); + }); + + it("should give scenario-end the scenario's own settled status", () => { + const scenario = service.start({ name: 'load' }); + scenario.fail({ error: new Error('boom') }); + + const steps = onlyScenarioRecord().steps; + expect(steps.at(-1)).toMatchObject({ + name: 'scenario-end', + status: 'failure' + }); + }); + + it('should close the open step when the scenario settles', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('fetch'); + scenario.complete(); + + expect(onlyScenarioRecord().steps[1].status).toBe('success'); + }); + + it('should mark the open step as failed when the scenario fails', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('fetch'); + scenario.fail({ error: new Error('network down') }); + + const step = onlyScenarioRecord().steps[1]; + expect(step.status).toBe('failure'); + expect(step.error).toMatchObject({ message: 'network down' }); + }); + + it("should merge the settle outcome's message and metadata into the step it closes", () => { + const scenario = service.start({ name: 'load' }); + scenario.step('fetch'); + scenario.complete({ + message: 'served from cache', + metadata: { cacheHit: true } + }); + + const step = onlyScenarioRecord().steps[1]; + expect(step).toMatchObject({ + name: 'fetch', + message: 'served from cache', + metadata: { cacheHit: true } + }); + }); + + it("should carry the settle outcome's reason onto the step it closes, independently of message", () => { + const scenario = service.start({ name: 'load' }); + scenario.step('fetch'); + scenario.cancel({ reason: 'user navigated away' }); + + const step = onlyScenarioRecord().steps[1]; + expect(step).toMatchObject({ + name: 'fetch', + reason: 'user navigated away' + }); + expect(step).not.toHaveProperty('message'); + }); + + it('should carry both message and reason, unmerged, onto the root record and scenario-end', () => { + const scenario = service.start({ name: 'load' }); + scenario.incomplete({ + message: 'search returned nothing to show', + reason: 'no-results' + }); + + const record = onlyScenarioRecord(); + expect(record).toMatchObject({ + message: 'search returned nothing to show', + reason: 'no-results' + }); + expect(record.steps.at(-1)).toMatchObject({ + name: 'scenario-end', + message: 'search returned nothing to show', + reason: 'no-results' + }); + }); + + it("should carry the settle outcome's message, metadata and error onto scenario-end", () => { + const scenario = service.start({ name: 'load' }); + scenario.fail({ + error: new Error('boom'), + message: 'checkout failed', + metadata: { step: 'payment' } + }); + + const end = onlyScenarioRecord().steps.at(-1); + expect(end).toMatchObject({ + name: 'scenario-end', + status: 'failure', + message: 'checkout failed', + metadata: { step: 'payment' }, + error: { message: 'boom' } + }); + }); + + it('should not carry an error onto scenario-end for a non-failure status', () => { + const scenario = service.start({ name: 'load' }); + scenario.settle('incomplete', undefined, new Error('irrelevant')); + + expect(onlyScenarioRecord().steps.at(-1)).not.toHaveProperty('error'); + }); + + it('should leave scenario-end without message or metadata when the outcome has none', () => { + const scenario = service.start({ name: 'load' }); + scenario.complete(); + + const end = onlyScenarioRecord().steps.at(-1); + expect(end).not.toHaveProperty('message'); + expect(end).not.toHaveProperty('metadata'); + }); + + it('should mark the open step as cancelled when the scenario is cancelled', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('fetch'); + scenario.cancel({ reason: 'user navigated away' }); + + expect(onlyScenarioRecord().steps[1].status).toBe('abandoned'); + }); + + it('should close a step explicitly via endStep', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('fetch').endStep({ message: 'cache hit' }); + scenario.complete(); + + expect(onlyScenarioRecord().steps[1]).toMatchObject({ + status: 'success', + message: 'cache hit' + }); + }); + + it('should fail a step without settling the scenario', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('optional-resource').failStep(new Error('404')); + + expect(scenario.status).toBeUndefined(); + expect(scenario.isSettled).toBe(false); + scenario.complete(); + + const record = onlyScenarioRecord(); + expect(record.status).toBe('success'); + expect(record.steps[1].status).toBe('failure'); + }); + + it('should tolerate endStep with no open step', () => { + const scenario = service.start({ name: 'load' }); + expect(() => scenario.endStep()).not.toThrow(); + scenario.complete(); + expect(onlyScenarioRecord().steps.map((s) => s.name)).toEqual([ + 'scenario-start', + 'scenario-end' + ]); + }); + + it('should count every real step but retain only up to maxSteps, plus the two markers', () => { + configure({ defaultTimeoutMs: 0, maxSteps: 3 }); + + const scenario = service.start({ name: 'loop' }); + for (let i = 0; i < 10; i++) { + scenario.step(`step-${i}`); + } + scenario.complete(); + + const record = onlyScenarioRecord(); + expect(record.stepCount).toBe(10); + expect(record.steps).toHaveLength(5); + expect(record.steps[0].name).toBe('scenario-start'); + expect(record.steps.at(-1)!.name).toBe('scenario-end'); + }); + + it('should retain every step and leave exceededStepsLimit unset at the exact maxSteps boundary', () => { + configure({ defaultTimeoutMs: 0, maxSteps: 3 }); + + const scenario = service.start({ name: 'loop' }); + scenario.step('a').step('b').step('c'); + scenario.complete(); + + const record = onlyScenarioRecord(); + expect(record.stepCount).toBe(3); + expect(record.steps).toHaveLength(5); + expect(record.exceededStepsLimit).toBeUndefined(); + }); + + it('should drop exactly one step and flag the limit at maxSteps + 1', () => { + configure({ defaultTimeoutMs: 0, maxSteps: 3 }); + + const scenario = service.start({ name: 'loop' }); + scenario.step('a').step('b').step('c').step('d'); + scenario.complete(); + + const record = onlyScenarioRecord(); + expect(record.stepCount).toBe(4); + expect(record.steps).toHaveLength(5); + expect(record.exceededStepsLimit).toBe(true); + }); + + it('should not redact metadata for a step dropped by the limit', () => { + const scrubbed: string[] = []; + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { + application: 'test-app', + environment: 'test', + version: '1.0.0' + }, + withScenarios({ defaultTimeoutMs: 0, maxSteps: 2 }), + withRedaction({ + extraValueTransforms: [ + (value) => { + scrubbed.push(value); + return value; + } + ] + }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const scoped = TestBed.inject(CpsScenarioTelemetryService); + + scoped + .start({ name: 'loop' }) + .step('a', { tag: 'kept-a' }) + .step('b', { tag: 'kept-b' }) + .step('c', { tag: 'dropped-c' }) + .step('d', { tag: 'dropped-d' }) + .complete(); + + expect(scrubbed).toEqual(['kept-a', 'kept-b']); + }); + + it('should still count and name a dropped step, and close it normally', () => { + configure({ defaultTimeoutMs: 0, maxSteps: 2 }); + + const scenario = service.start({ name: 'loop' }); + scenario.step('a').step('b').step('c', { tag: 'dropped' }); + scenario.complete(); + + const record = onlyScenarioRecord(); + expect(record.stepCount).toBe(3); + expect(record.previousStep).toBe('c'); + expect(record.exceededStepsLimit).toBe(true); + expect(record.steps.map((s) => s.name)).toEqual([ + 'scenario-start', + 'a', + 'b', + 'scenario-end' + ]); + }); + + it('should retain no real steps and flag the limit immediately when maxSteps is 0', () => { + configure({ defaultTimeoutMs: 0, maxSteps: 0 }); + + const scenario = service.start({ name: 'loop' }); + scenario.step('a'); + scenario.complete(); + + const record = onlyScenarioRecord(); + expect(record.stepCount).toBe(1); + expect(record.steps.map((s) => s.name)).toEqual([ + 'scenario-start', + 'scenario-end' + ]); + expect(record.exceededStepsLimit).toBe(true); + }); + + it('should redact sensitive step metadata', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('auth', { token: 'abc', attempt: 1 }); + scenario.complete(); + + expect(onlyScenarioRecord().steps[1].metadata).toEqual({ + token: CPS_REDACTED, + attempt: 1 + }); + }); + }); + + describe('outcomes', () => { + it('should settle as completed with a status code and metadata', () => { + const scenario = service.start({ name: 'load' }); + scenario.complete({ + statusCode: 200, + message: 'ok', + metadata: { rowCount: 42 } + }); + + expect(onlyScenarioRecord()).toMatchObject({ + status: 'success', + statusCode: 200, + message: 'ok', + metadata: { rowCount: 42 } + }); + }); + + it('should settle as failed with a normalized error', () => { + const scenario = service.start({ name: 'load' }); + scenario.fail({ error: new TypeError('bad shape'), statusCode: 500 }); + + const record = onlyScenarioRecord(); + expect(record.status).toBe('failure'); + expect(record.statusCode).toBe(500); + expect(record.error).toMatchObject({ + name: 'TypeError', + message: 'bad shape' + }); + }); + + it('should settle as failed even with no error supplied', () => { + service.start({ name: 'load' }).fail(); + expect(onlyScenarioRecord().status).toBe('failure'); + }); + + it('should settle as cancelled carrying the reason', () => { + service.start({ name: 'load' }).cancel({ reason: 'user navigated away' }); + + const record = onlyScenarioRecord(); + expect(record).toMatchObject({ + status: 'abandoned', + reason: 'user navigated away', + metadata: { abandonedBy: 'caller' } + }); + expect(record).not.toHaveProperty('message'); + }); + + it('should settle as cancelled carrying additional outcome metadata and status code', () => { + service.start({ name: 'load' }).cancel({ + reason: 'dialog-closed', + statusCode: 499, + metadata: { dialogButton: 'cancel', stepIndex: 2 } + }); + + expect(onlyScenarioRecord()).toMatchObject({ + status: 'abandoned', + reason: 'dialog-closed', + statusCode: 499, + metadata: { + abandonedBy: 'caller', + dialogButton: 'cancel', + stepIndex: 2 + } + }); + }); + + it("should not let caller metadata override cancel()'s own abandonedBy: caller", () => { + service.start({ name: 'load' }).cancel({ + metadata: { abandonedBy: 'page-hidden' } + }); + + expect(onlyScenarioRecord().metadata).toMatchObject({ + abandonedBy: 'caller' + }); + }); + + it('should return a shallow copy of steps from toRecord so external mutations do not affect internal state', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('first-step'); + const record = scenario.toRecord(); + + expect(record.steps).toHaveLength(2); + record.steps.pop(); + + scenario.complete(); + const finalRecord = onlyScenarioRecord(); + expect(finalRecord.steps).toHaveLength(3); + }); + + it('should carry the optional classification fields', () => { + service + .start({ + name: 'export', + feature: 'customers', + operation: 'csv-export', + route: '/customers', + parentScenarioId: 'parent-1', + metadata: { source: 'toolbar' } + }) + .complete(); + + expect(onlyScenarioRecord()).toMatchObject({ + scenarioName: 'export', + feature: 'customers', + operation: 'csv-export', + route: '/customers', + parentScenarioId: 'parent-1', + metadata: { source: 'toolbar' } + }); + }); + + it('should strip a query string from route, the same as any other URL-shaped string field', () => { + service.start({ name: 'export', route: '/customers?id=42' }).complete(); + + expect(onlyScenarioRecord()).toMatchObject({ route: '/customers' }); + }); + + it('should length-cap feature/operation/route, the same as any other string field', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withScenarios({ defaultTimeoutMs: 0 }), + withRedaction({ maxStringLength: 4 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const capped = TestBed.inject(CpsScenarioTelemetryService); + const cappedSink = TestBed.inject(RecordingSink); + + capped + .start({ + name: 'export', + feature: 'customers', + operation: 'csv-export', + route: '/customers' + }) + .complete(); + + const record = cappedSink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario)[0] + .payload as Record<string, unknown>; + expect(record).toMatchObject({ + feature: 'cust…', + operation: 'csv-…', + route: '/cus…' + }); + }); + + it('should not carry browser, device or page fields already in the RUM envelope', () => { + service.start({ name: 'load' }).complete(); + const record = onlyScenarioRecord() as unknown as Record<string, unknown>; + + expect(record.browser).toBeUndefined(); + expect(record.device).toBeUndefined(); + expect(record.page).toBeUndefined(); + }); + + it('should stamp application from config and sessionId from the sink', () => { + service.start({ name: 'load' }).complete(); + const record = onlyScenarioRecord(); + + expect(record.application).toBe('test-app'); + expect(record.sessionId).toBe('test-session'); + }); + + it('should leave userId absent until the sink has one, then carry it', () => { + const first = service.start({ name: 'load' }); + first.complete(); + expect(onlyScenarioRecord().userId).toBeUndefined(); + + sink.setUserId('user-42'); + const second = service.start({ name: 'load' }); + second.complete(); + + const records = sink + .ofType(CPS_TELEMETRY_EVENT_TYPE.scenario) + .map((e) => e.payload as unknown as CpsScenarioRecord); + expect(records[1].userId).toBe('user-42'); + }); + }); + + describe('settle', () => { + it.each([ + ['success'], + ['failure'], + ['abandoned'], + ['incomplete'], + ['timeout'] + ] as [CpsScenarioStatus][])( + 'should settle into %s from a status supplied as data', + (status) => { + service.start({ name: 'load' }).settle(status); + expect(onlyScenarioRecord().status).toBe(status); + } + ); + + it('should behave identically to the named method', () => { + service.start({ name: 'a' }).settle('success', { statusCode: 200 }); + service.start({ name: 'b' }).complete({ statusCode: 200 }); + + const [viaSettle, viaNamed] = sink + .ofType(CPS_TELEMETRY_EVENT_TYPE.scenario) + .map((e) => e.payload as unknown as CpsScenarioRecord); + + expect(viaSettle.status).toBe(viaNamed.status); + expect(viaSettle.statusCode).toBe(viaNamed.statusCode); + }); + + it('should accept an error when settling as failed', () => { + service + .start({ name: 'load' }) + .settle('failure', { statusCode: 500 }, new TypeError('bad shape')); + + expect(onlyScenarioRecord()).toMatchObject({ + status: 'failure', + statusCode: 500, + error: { name: 'TypeError', message: 'bad shape' } + }); + }); + + it('should close the open step with the settling status', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('fetch'); + scenario.settle('incomplete'); + + expect(onlyScenarioRecord().steps[1].status).toBe('incomplete'); + }); + + it('should be absorbing, like the named methods', () => { + const scenario = service.start({ name: 'load' }); + scenario.settle('failure'); + scenario.settle('success'); + + expect(scenario.status).toBe('failure'); + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario)).toHaveLength(1); + }); + + it('should let an adapter map an external status onto a scenario', () => { + const fromMessage: Record<string, CpsScenarioStatus> = { + ok: 'success', + error: 'failure', + superseded: 'abandoned' + }; + + const scenario = service.start({ name: 'remote-job' }); + scenario.settle(fromMessage.error); + + expect(onlyScenarioRecord().status).toBe('failure'); + }); + }); + + describe('terminal states are absorbing', () => { + it('should ignore a second settle call', () => { + const scenario = service.start({ name: 'load' }); + scenario.complete(); + scenario.fail({ error: new Error('too late') }); + scenario.cancel(); + + expect(scenario.status).toBe('success'); + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario)).toHaveLength(1); + }); + + it('should ignore step mutations after settling', () => { + const scenario = service.start({ name: 'load' }); + scenario.complete(); + + scenario.step('late').endStep().failStep(new Error()); + + expect(onlyScenarioRecord().steps.map((s) => s.name)).toEqual([ + 'scenario-start', + 'scenario-end' + ]); + }); + + it('should never throw when used after settling', () => { + const scenario = service.start({ name: 'load' }); + scenario.fail({ error: new Error('boom') }); + expect(() => scenario.step('late').complete()).not.toThrow(); + }); + }); + + describe('abandoned', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + it('should settle as timeout when the deadline passes', () => { + const scenario = service.start({ name: 'slow', timeoutMs: 5000 }); + + jest.advanceTimersByTime(5000); + + expect(scenario.status).toBe('timeout'); + expect(onlyScenarioRecord()).toMatchObject({ + status: 'timeout', + message: 'Scenario did not settle within 5000ms' + }); + }); + + it('should not record an abandonedBy cause for a timeout', () => { + service.start({ name: 'slow', timeoutMs: 5000 }); + + jest.advanceTimersByTime(5000); + + expect(onlyScenarioRecord().metadata).toBeUndefined(); + }); + + it('should record caller as the cause for an explicit cancel', () => { + service + .start({ name: 'quick' }) + .cancel({ reason: 'user navigated away' }); + + expect(onlyScenarioRecord()).toMatchObject({ + reason: 'user navigated away', + metadata: { abandonedBy: 'caller' } + }); + }); + + it('should record page-hidden as the cause at unload', () => { + service.start({ name: 'in-flight' }); + + window.dispatchEvent(new Event('pagehide')); + + const record = onlyScenarioRecord(); + expect(record.metadata).toMatchObject({ abandonedBy: 'page-hidden' }); + expect(record.reason).toBe('page-hidden'); + }); + + it('should not fire the timeout once the scenario has settled', () => { + const scenario = service.start({ name: 'quick', timeoutMs: 5000 }); + scenario.complete(); + + jest.advanceTimersByTime(10000); + + expect(scenario.status).toBe('success'); + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario)).toHaveLength(1); + }); + + it('should apply the configured default timeout', () => { + configure({ defaultTimeoutMs: 1000 }); + const scenario = service.start({ name: 'slow' }); + + jest.advanceTimersByTime(1000); + + expect(scenario.status).toBe('timeout'); + }); + + it('should not schedule a timeout when it is zero', () => { + const scenario = service.start({ name: 'unbounded', timeoutMs: 0 }); + jest.advanceTimersByTime(600_000); + expect(scenario.status).toBeUndefined(); + expect(scenario.isSettled).toBe(false); + }); + + it("should not fire near-instantly for a timeoutMs beyond setTimeout's 32-bit limit", () => { + const scenario = service.start({ + name: 'huge', + timeoutMs: 30 * 24 * 60 * 60 * 1000 + }); + + jest.advanceTimersByTime(1000); + expect(scenario.status).toBeUndefined(); + }); + + it('should reschedule rather than settle early when a clamped timeout hop fires', () => { + const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; + const scenario = service.start({ name: 'huge', timeoutMs: thirtyDaysMs }); + + jest.advanceTimersByTime(2_147_483_647); + expect(scenario.status).toBeUndefined(); + + jest.advanceTimersByTime(thirtyDaysMs - 2_147_483_647); + expect(scenario.status).toBe('timeout'); + }); + }); + + describe('correlation', () => { + it('should not require any logging to be wired', () => { + expect(() => service.start({ name: 'load' }).complete()).not.toThrow(); + }); + + it('should use the scenario id as the emitted correlation identifier', () => { + const scenario = service.start({ name: 'load' }); + const id = scenario.id; + scenario.complete(); + + expect(onlyScenarioRecord().scenarioId).toBe(id); + }); + }); + + describe('emission mode', () => { + it('should emit exactly one event per scenario by default', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('one').step('two').step('three'); + scenario.complete(); + + expect(sink.events).toHaveLength(1); + expect(sink.events[0].eventType).toBe(CPS_TELEMETRY_EVENT_TYPE.scenario); + expect( + (sink.events[0].payload as unknown as CpsScenarioRecord).steps + ).toHaveLength(5); // scenario-start + 3 real + scenario-end + }); + + it('should emit each step and the settlement in verbose mode', () => { + configure({ emitLifecycleEvents: true }); + + const scenario = service.start({ name: 'load' }); + scenario.step('one').step('two'); + scenario.complete(); + + expect(sink.events).toHaveLength(3); + expect(sink.events.map((e) => e.eventType)).toEqual([ + CPS_TELEMETRY_EVENT_TYPE.scenarioStep, + CPS_TELEMETRY_EVENT_TYPE.scenarioStep, + CPS_TELEMETRY_EVENT_TYPE.scenario + ]); + + expect(sink.events[2].payload.status).toBe('success'); + expect(sink.events[0].payload.scenarioId).toBe(scenario.id); + }); + + it('should stamp application, sessionId and userId on each verbose step event, same as the packed record', () => { + configure({ emitLifecycleEvents: true }); + sink.setUserId('user-42'); + + const scenario = service.start({ name: 'load' }); + scenario.step('one'); + scenario.complete(); + + const stepEvent = sink.events.find( + (e) => e.eventType === CPS_TELEMETRY_EVENT_TYPE.scenarioStep + ); + expect(stepEvent?.payload.application).toBe('test-app'); + expect(stepEvent?.payload.sessionId).toBe('test-session'); + expect(stepEvent?.payload.userId).toBe('user-42'); + }); + + it('should stop emitting step events once maxSteps is exceeded', () => { + configure({ emitLifecycleEvents: true, maxSteps: 2 }); + + const scenario = service.start({ name: 'load' }); + scenario.step('one').step('two').step('three').step('four'); + scenario.complete(); + + const stepEvents = sink.events.filter( + (e) => e.eventType === CPS_TELEMETRY_EVENT_TYPE.scenarioStep + ); + expect(stepEvents).toHaveLength(2); + }); + + it("should close the open step with the scenario's own terminal status in verbose mode", () => { + configure({ emitLifecycleEvents: true }); + + const scenario = service.start({ name: 'load' }); + scenario.step('fetch'); + scenario.fail({ error: new Error('boom') }); + + const stepEvents = sink.events.filter( + (e) => e.eventType === CPS_TELEMETRY_EVENT_TYPE.scenarioStep + ); + expect(stepEvents).toHaveLength(1); + expect(stepEvents[0].payload).toMatchObject({ + name: 'fetch', + status: 'failure' + }); + + const record = onlyScenarioRecord(); + expect(record.steps.find((s) => s.name === 'fetch')).toMatchObject({ + status: 'failure' + }); + }); + }); + + describe('debug output', () => { + let consoleLog: jest.SpyInstance; + + beforeEach(() => { + consoleLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + it('should stay silent by default', () => { + service.start({ name: 'load' }).step('one').complete(); + expect(consoleLog).not.toHaveBeenCalled(); + }); + + it.each(['true', '1'])( + 'should log only what it sends when debugScenario is "%s"', + (value) => { + localStorage.setItem('debugScenario', value); + + service.start({ name: 'load' }).step('one').complete(); + + expect(consoleLog).toHaveBeenCalledWith( + expect.stringMatching( + /^\[test-app]\[scenario] load success in \d+ms -> com\.cps\.scenario$/ + ), + expect.any(Object) + ); + + expect(consoleLog).not.toHaveBeenCalledWith( + '[test-app][scenario] load started', + expect.anything() + ); + expect(consoleLog).not.toHaveBeenCalledWith( + '[test-app][scenario] load step one', + expect.anything() + ); + } + ); + + it('should never log a step-open line, even when emitLifecycleEvents is on', () => { + configure({ emitLifecycleEvents: true }); + localStorage.setItem('debugScenario', 'true'); + + service.start({ name: 'load' }).step('one').complete(); + + expect(consoleLog).not.toHaveBeenCalledWith( + '[test-app][scenario] load step one', + expect.anything() + ); + }); + + it('should log the step-close line, carrying the exact payload sent, when emitLifecycleEvents is on', () => { + configure({ emitLifecycleEvents: true }); + localStorage.setItem('debugScenario', 'true'); + + service.start({ name: 'load' }).step('one').step('two').complete(); + + const closeOneCall = consoleLog.mock.calls.find( + ([label]: [string]) => + label === + '[test-app][scenario] load step one -> com.cps.scenario.step' + ); + const sentStepOne = sink + .ofType(CPS_TELEMETRY_EVENT_TYPE.scenarioStep) + .find((e) => (e.payload as Record<string, unknown>).name === 'one'); + + expect(closeOneCall?.[1]).toBe(sentStepOne?.payload); + }); + + it('should log one line per sink event — same payloads, same order, nothing extra', () => { + configure({ emitLifecycleEvents: true }); + localStorage.setItem('debugScenario', 'true'); + + const scenario = service.start({ name: 'load' }); + scenario.step('one').step('two').step('three'); + scenario.complete(); + scenario.step('after-settle'); + + const logged = consoleLog.mock.calls.map(([, payload]) => payload); + const sent = sink.events.map((event) => event.payload); + + expect(logged).toHaveLength(sent.length); + logged.forEach((payload, i) => expect(payload).toBe(sent[i])); + }); + + it('should log the very record handed to the sink', () => { + localStorage.setItem('debugScenario', 'true'); + service.start({ name: 'load' }).step('one').complete(); + + const settleCall = consoleLog.mock.calls.find(([label]: [string]) => + label.includes('success in') + ); + const sent = sink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario)[0]; + + expect(settleCall?.[1]).toBe(sent.payload); + }); + + it('should stay silent for an operation ignored after settling', () => { + localStorage.setItem('debugScenario', 'true'); + const scenario = service.start({ name: 'load' }); + scenario.complete(); + + const afterSettle = consoleLog.mock.calls.length; + scenario.step('late'); + + expect(consoleLog.mock.calls).toHaveLength(afterSettle); + }); + + it('should still record the scenario when the console itself throws', () => { + localStorage.setItem('debugScenario', 'true'); + consoleLog.mockImplementation(() => { + throw new Error('console is patched and broken'); + }); + + expect(() => + service.start({ name: 'load' }).step('one').complete() + ).not.toThrow(); + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario)).toHaveLength(1); + }); + + it('should stay silent for an invalid debugScenario value', () => { + localStorage.setItem('debugScenario', 'yes'); + service.start({ name: 'load' }).complete(); + expect(consoleLog).not.toHaveBeenCalled(); + }); + }); + + describe('page unload', () => { + it('should abandon in-flight scenarios and beacon-flush on pagehide', () => { + const a = service.start({ name: 'a' }); + const b = service.start({ name: 'b' }); + b.complete(); + + window.dispatchEvent(new Event('pagehide')); + + expect(a.status).toBe('abandoned'); + const abandoned = sink + .ofType(CPS_TELEMETRY_EVENT_TYPE.scenario) + .map((e) => e.payload as unknown as CpsScenarioRecord) + .find((r) => r.scenarioName === 'a'); + expect(abandoned?.status).toBe('abandoned'); + expect(abandoned?.reason).toBe('page-hidden'); + expect(sink.flushes).toContain(true); + }); + + it('should leave an already-completed scenario alone at unload', () => { + const done = service.start({ name: 'b' }); + done.complete(); + + window.dispatchEvent(new Event('pagehide')); + + expect(done.status).toBe('success'); + }); + + it('should abandon in-flight scenarios on destroy', () => { + const scenario = service.start({ name: 'a' }); + service.ngOnDestroy(); + expect(scenario.status).toBe('abandoned'); + }); + + it('should flush the sink when the page is hidden, without settling', () => { + const scenario = service.start({ name: 'a' }); + + jest.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + + expect(sink.flushes).toEqual([true]); + expect(scenario.status).toBeUndefined(); + expect(scenario.isSettled).toBe(false); + expect(sink.events).toHaveLength(0); + }); + + it('should not flush when the page becomes visible again', () => { + service.start({ name: 'a' }); + + jest.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible'); + document.dispatchEvent(new Event('visibilitychange')); + + expect(sink.flushes).toEqual([]); + }); + + it('should stop listening for visibility changes after destroy', () => { + service.ngOnDestroy(); + sink.flushes.length = 0; + + jest.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + + expect(sink.flushes).toEqual([]); + }); + + it('should stop listening after destroy', () => { + service.ngOnDestroy(); + const scenario = service.start({ name: 'a' }); + + window.dispatchEvent(new Event('pagehide')); + + expect(scenario.status).toBeUndefined(); + expect(scenario.isSettled).toBe(false); + }); + }); + + describe('incomplete', () => { + it('should settle as incomplete, apart from failed and cancelled', () => { + service.start({ name: 'search' }).incomplete({ reason: 'no-results' }); + + expect(onlyScenarioRecord()).toMatchObject({ + status: 'incomplete', + reason: 'no-results' + }); + }); + + it('should accept a status code and metadata', () => { + service.start({ name: 'search' }).incomplete({ + reason: 'no-results', + statusCode: 204, + metadata: { hits: 0 } + }); + + expect(onlyScenarioRecord()).toMatchObject({ + status: 'incomplete', + statusCode: 204, + metadata: { hits: 0 } + }); + }); + + it('should mark the open step incomplete, not failed', () => { + const scenario = service.start({ name: 'search' }); + scenario.step('query'); + scenario.incomplete({ reason: 'no-results' }); + + expect(onlyScenarioRecord().steps[1].status).toBe('incomplete'); + }); + + it('should be absorbing', () => { + const scenario = service.start({ name: 'search' }); + scenario.incomplete({ reason: 'no-results' }); + scenario.complete(); + + expect(scenario.status).toBe('incomplete'); + expect(sink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario)).toHaveLength(1); + }); + + it('should carry no error object', () => { + service.start({ name: 'search' }).incomplete({ reason: 'no-results' }); + expect(onlyScenarioRecord().error).toBeUndefined(); + }); + }); + + describe('setData', () => { + it('should merge attributes recorded mid-flight', () => { + const scenario = service.start({ name: 'load' }); + scenario.setData({ rowCount: 42 }); + scenario.complete(); + + expect(onlyScenarioRecord().metadata).toMatchObject({ rowCount: 42 }); + }); + + it('should merge with start and settle metadata', () => { + const scenario = service.start({ + name: 'load', + metadata: { source: 'toolbar' } + }); + scenario.setData({ strategy: 'cache' }); + scenario.complete({ metadata: { rowCount: 3 } }); + + expect(onlyScenarioRecord().metadata).toEqual({ + source: 'toolbar', + strategy: 'cache', + rowCount: 3 + }); + }); + + it('should let a later call overwrite an earlier one', () => { + const scenario = service.start({ name: 'load' }); + scenario.setData({ strategy: 'cache' }); + scenario.setData({ strategy: 'network' }); + scenario.complete(); + + expect(onlyScenarioRecord().metadata).toMatchObject({ + strategy: 'network' + }); + }); + + it('should redact sensitive attributes', () => { + const scenario = service.start({ name: 'load' }); + scenario.setData({ token: 'abc', rows: 1 }); + scenario.complete(); + + expect(onlyScenarioRecord().metadata).toMatchObject({ + token: CPS_REDACTED, + rows: 1 + }); + }); + + it('should still redact via the built-in credential denylist when withScenarios({ redact: false })', () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withScenarios({ defaultTimeoutMs: 0, redact: false }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink } + ] + }); + const unredacted = TestBed.inject(CpsScenarioTelemetryService); + const unredactedSink = TestBed.inject(RecordingSink); + + unredacted + .start({ name: 'load' }) + .setData({ token: 'abc', rows: 1 }) + .complete(); + + const record = unredactedSink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario)[0] + .payload as Record<string, unknown>; + + expect(record.metadata).toMatchObject({ + token: CPS_REDACTED, + rows: 1 + }); + }); + + it('should be ignored after settling', () => { + const scenario = service.start({ name: 'load' }); + scenario.complete(); + scenario.setData({ late: true }); + + expect(onlyScenarioRecord().metadata).toBeUndefined(); + }); + }); + + describe('backdated start', () => { + it('should measure from the supplied epoch timestamp', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(5_000); + + const scenario = service.start({ + name: 'nav', + startedAt: performance.timeOrigin + 3_000 + }); + + nowSpy.mockReturnValue(5_500); + scenario.complete(); + + expect(onlyScenarioRecord().delta).toBe(2_500); + }); + + it('should ignore a timestamp from the future', () => { + const scenario = service.start({ + name: 'nav', + startedAt: Date.now() + 60_000 + }); + scenario.complete(); + + expect(onlyScenarioRecord().delta).toBeGreaterThanOrEqual(0); + expect(onlyScenarioRecord().delta).toBeLessThan(1_000); + }); + + it('should ignore a timestamp from before the page loaded', () => { + const scenario = service.start({ name: 'nav', startedAt: 0 }); + scenario.complete(); + + expect(onlyScenarioRecord().delta).toBeGreaterThanOrEqual(0); + expect(onlyScenarioRecord().delta).toBeLessThan(1_000); + }); + + it('should behave normally when omitted', () => { + service.start({ name: 'nav' }).complete(); + expect(onlyScenarioRecord().delta).toBeLessThan(1_000); + }); + + it('should backdate startTime to match the backdated delta', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(10_000); + + const scenario = service.start({ + name: 'nav', + startedAt: performance.timeOrigin + 6_000 + }); + nowSpy.mockReturnValue(10_000); + scenario.complete(); + + const record = onlyScenarioRecord(); + const startMs = new Date(record.startTime).getTime(); + const endMs = new Date(record.endTime!).getTime(); + expect(endMs - startMs).toBe(record.delta); + }); + + it('should shorten the scheduled timeout by however much startedAt already backdates it', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(5_000); + const setTimeoutSpy = jest + .spyOn(globalThis, 'setTimeout') + .mockReturnValue(0 as unknown as ReturnType<typeof setTimeout>); + + service.start({ + name: 'nav', + timeoutMs: 5_000, + startedAt: performance.timeOrigin + 2_000 + }); + + expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 2_000); + }); + }); + + describe('aggregates', () => { + it('should sum repeated calls of one operation', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(0); + const scenario = service.start({ name: 'render-table' }); + + nowSpy.mockReturnValue(100); + scenario.aggregateStart('format-cell'); + nowSpy.mockReturnValue(130); + scenario.aggregateEnd('format-cell'); + + nowSpy.mockReturnValue(200); + scenario.aggregateStart('format-cell'); + nowSpy.mockReturnValue(220); + scenario.aggregateEnd('format-cell'); + + scenario.complete(); + + expect(onlyScenarioRecord().aggregates).toEqual([ + { name: 'format-cell', elapsed: 50, callCount: 2 } + ]); + }); + + it('should keep separate operations apart', () => { + const scenario = service.start({ name: 'render' }); + scenario.aggregateStart('a').aggregateEnd('a'); + scenario.aggregateStart('b').aggregateEnd('b'); + scenario.complete(); + + expect(onlyScenarioRecord().aggregates?.map((a) => a.name)).toEqual([ + 'a', + 'b' + ]); + }); + + it('should ignore an end with no matching start', () => { + const scenario = service.start({ name: 'render' }); + scenario.aggregateEnd('never-started'); + scenario.complete(); + + expect(onlyScenarioRecord().aggregates).toBeUndefined(); + }); + + it('should not double-count an overlapping start', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(0); + const scenario = service.start({ name: 'render' }); + + nowSpy.mockReturnValue(100); + scenario.aggregateStart('op'); + nowSpy.mockReturnValue(150); + scenario.aggregateStart('op'); // ignored — already open + nowSpy.mockReturnValue(300); + scenario.aggregateEnd('op'); + scenario.complete(); + + expect(onlyScenarioRecord().aggregates).toEqual([ + { name: 'op', elapsed: 200, callCount: 1 } + ]); + }); + + it('should close an aggregate still open when the scenario settles', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(0); + const scenario = service.start({ name: 'render' }); + + nowSpy.mockReturnValue(100); + scenario.aggregateStart('op'); + nowSpy.mockReturnValue(400); + scenario.complete(); + + expect(onlyScenarioRecord().aggregates).toEqual([ + { name: 'op', elapsed: 300, callCount: 1 } + ]); + }); + + it('should freeze an aggregate left open at settlement, not keep advancing on later toRecord() calls', () => { + const nowSpy = jest.spyOn(performance, 'now'); + nowSpy.mockReturnValue(0); + const scenario = service.start({ name: 'render' }); + + nowSpy.mockReturnValue(100); + scenario.aggregateStart('op'); + nowSpy.mockReturnValue(400); + scenario.complete(); + + const atSettlement = scenario.toRecord().aggregates; + expect(atSettlement).toEqual([ + { name: 'op', elapsed: 300, callCount: 1 } + ]); + + nowSpy.mockReturnValue(10_000); + const muchLater = scenario.toRecord().aggregates; + + expect(muchLater).toEqual(atSettlement); + }); + + it('should omit the field when nothing was aggregated', () => { + service.start({ name: 'render' }).complete(); + expect(onlyScenarioRecord().aggregates).toBeUndefined(); + }); + + it('should be ignored after settling', () => { + const scenario = service.start({ name: 'render' }); + scenario.complete(); + scenario.aggregateStart('late').aggregateEnd('late'); + + expect(onlyScenarioRecord().aggregates).toBeUndefined(); + }); + }); + + describe('trace and ordering fields', () => { + it('should emit one event per lifecycle transition in verbose mode', () => { + configure({ emitLifecycleEvents: true }); + + const scenario = service.start({ name: 'load' }); + scenario.step('one').step('two'); + scenario.complete(); + + expect(sink.events).toHaveLength(3); + }); + + it('should name the last step closed before settling', () => { + const scenario = service.start({ name: 'load' }); + scenario.step('first').step('second'); + scenario.complete(); + + expect(onlyScenarioRecord().previousStep).toBe('second'); + }); + + it('should have no previousStep when no step was opened', () => { + service.start({ name: 'load' }).complete(); + expect(onlyScenarioRecord().previousStep).toBeUndefined(); + }); + }); + + describe('event namespace', () => { + it('should emit under the default namespace', () => { + service.start({ name: 'load' }).complete(); + expect(sink.events[0].eventType).toBe('com.cps.scenario'); + }); + + it('should emit scenario and step events under a configured namespace', () => { + configure({ emitLifecycleEvents: true }, 'com.test-app'); + + const scenario = service.start({ name: 'load' }); + scenario.step('one'); + scenario.complete(); + + expect(sink.events.map((e) => e.eventType)).toEqual([ + 'com.test-app.scenario.step', + 'com.test-app.scenario' + ]); + }); + }); + + describe('exceededStepsLimit', () => { + it('should flag a truncated step list', () => { + configure({ defaultTimeoutMs: 0, maxSteps: 2 }); + + const scenario = service.start({ name: 'loop' }); + for (let i = 0; i < 5; i++) { + scenario.step(`s${i}`); + } + scenario.complete(); + + const record = onlyScenarioRecord(); + expect(record.exceededStepsLimit).toBe(true); + expect(record.stepCount).toBe(5); + expect(record.steps).toHaveLength(4); // scenario-start + 2 real + scenario-end + }); + + it('should be absent when every step was retained', () => { + const scenario = service.start({ name: 'loop' }); + scenario.step('one').step('two'); + scenario.complete(); + + expect(onlyScenarioRecord().exceededStepsLimit).toBeUndefined(); + }); + }); + + describe('user timings', () => { + let perfApi: { + mark: jest.Mock; + measure: jest.Mock; + clearMarks: jest.Mock; + clearMeasures: jest.Mock; + }; + const realPerformance = globalThis.performance; + + /** + * jsdom has no mark/measure, so the API is installed for these tests. + * The clock is whichever `performance` is current — Jest's fake one + * after `useFakeTimers()` — so time advanced by the test is time seen. + */ + function installPerf(): void { + const clock = globalThis.performance; + perfApi = { + mark: jest.fn(), + measure: jest.fn(), + clearMarks: jest.fn(), + clearMeasures: jest.fn() + }; + Object.defineProperty(globalThis, 'performance', { + value: { + ...perfApi, + now: () => clock.now(), + timeOrigin: clock.timeOrigin, + getEntriesByName: () => [] + }, + // required so later tests can still redefine window.performance + writable: true, + configurable: true + }); + } + + afterEach(() => { + Object.defineProperty(globalThis, 'performance', { + value: realPerformance, + writable: true, + configurable: true + }); + }); + + it('should emit nothing when disabled', () => { + installPerf(); + service.start({ name: 'load' }).step('one').complete(); + + expect(perfApi.mark).not.toHaveBeenCalled(); + expect(perfApi.measure).not.toHaveBeenCalled(); + }); + + it('should mark start, steps and settle when enabled by config', () => { + configure({ defaultTimeoutMs: 0, userTimings: true }); + installPerf(); + + const scenario = service.start({ name: 'load' }); + scenario.step('fetch'); + scenario.complete(); + + const marked = perfApi.mark.mock.calls.map((c) => c[0] as string); + expect(marked).toContain(`test-app:load:start:${scenario.id}`); + expect(marked).toContain(`test-app:load:fetch:${scenario.id}`); + expect(marked).toContain(`test-app:load:settle:${scenario.id}`); + }); + + it('should not collide marks between concurrent scenarios of the same name', () => { + configure({ defaultTimeoutMs: 0, userTimings: true }); + installPerf(); + + const first = service.start({ name: 'load' }); + first.step('fetch'); + const second = service.start({ name: 'load' }); + second.step('fetch'); + + first.complete(); + second.complete(); + + const marked = perfApi.mark.mock.calls.map((c) => c[0] as string); + expect(marked).toContain(`test-app:load:fetch:${first.id}`); + expect(marked).toContain(`test-app:load:fetch:${second.id}`); + expect(new Set(marked).size).toBe(marked.length); + + const cleared = perfApi.clearMarks.mock.calls.map((c) => c[0] as string); + expect(cleared).toContain(`test-app:load:settle:${first.id}`); + expect(cleared).toContain(`test-app:load:settle:${second.id}`); + }); + + it('should be enabled by the debugScenario flag with config off', () => { + localStorage.setItem('debugScenario', 'true'); + jest.spyOn(console, 'log').mockImplementation(() => {}); + installPerf(); + + service.start({ name: 'load' }).complete(); + + expect(perfApi.mark).toHaveBeenCalled(); + }); + + it('should measure each step and the whole scenario', () => { + configure({ defaultTimeoutMs: 0, userTimings: true }); + installPerf(); + + const scenario = service.start({ name: 'load', feature: 'customers' }); + scenario.step('fetch').step('render'); + scenario.complete(); + + const measured = perfApi.measure.mock.calls.map((c) => c[0] as string); + expect(measured).toContain('load [fetch]'); + expect(measured).toContain('load [render]'); + expect(measured).toContain('load (customers)'); + }); + + it('should clear its marks once the scenario settles', () => { + configure({ defaultTimeoutMs: 0, userTimings: true }); + installPerf(); + + const scenario = service.start({ name: 'load' }); + scenario.step('fetch'); + scenario.complete(); + + const cleared = perfApi.clearMarks.mock.calls.map((c) => c[0] as string); + expect(cleared).toContain(`test-app:load:start:${scenario.id}`); + expect(cleared).toContain(`test-app:load:settle:${scenario.id}`); + }); + + it('should clear its marks via the configured fallback when it has no timeout and never settles', () => { + jest.useFakeTimers(); + configure({ userTimings: true, markCleanupFallbackMs: 90_000 }); + installPerf(); + + const scenario = service.start({ name: 'load' }); + scenario.step('fetch').step('render'); + + jest.advanceTimersByTime(89_999); + expect(perfApi.clearMarks).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + const cleared = perfApi.clearMarks.mock.calls.map((c) => c[0] as string); + expect(cleared).toContain(`test-app:load:start:${scenario.id}`); + expect(cleared).toContain(`test-app:load:fetch:${scenario.id}`); + + jest.useRealTimers(); + }); + + it('should not schedule a mark cleanup fallback when a real timeout is configured', () => { + jest.useFakeTimers(); + configure({ + defaultTimeoutMs: 10 * 60 * 1000, + userTimings: true, + markCleanupFallbackMs: 1_000 + }); + installPerf(); + + service.start({ name: 'load' }).step('fetch'); + + jest.advanceTimersByTime(60_000); + + expect(perfApi.clearMarks).not.toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it('should disable the fallback entirely when markCleanupFallbackMs is 0', () => { + jest.useFakeTimers(); + configure({ userTimings: true, markCleanupFallbackMs: 0 }); + installPerf(); + + service.start({ name: 'load' }).step('fetch'); + + jest.advanceTimersByTime(60 * 60 * 1000); + + expect(perfApi.clearMarks).not.toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it("should not fire near-instantly for a markCleanupFallbackMs beyond setTimeout's 32-bit limit", () => { + jest.useFakeTimers(); + + configure({ + defaultTimeoutMs: 0, + userTimings: true, + markCleanupFallbackMs: 30 * 24 * 60 * 60 * 1000 + }); + installPerf(); + + service.start({ name: 'load' }).step('fetch'); + + jest.advanceTimersByTime(1000); + + expect(perfApi.clearMarks).not.toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it('should reschedule rather than clear early when a clamped fallback hop fires', () => { + jest.useFakeTimers(); + const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; + configure({ + defaultTimeoutMs: 0, + userTimings: true, + markCleanupFallbackMs: thirtyDaysMs + }); + installPerf(); + + service.start({ name: 'load' }).step('fetch'); + + jest.advanceTimersByTime(2_147_483_647); + expect(perfApi.clearMarks).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(thirtyDaysMs - 2_147_483_647 - 1); + expect(perfApi.clearMarks).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + expect(perfApi.clearMarks).toHaveBeenCalled(); + + jest.useRealTimers(); + }); + + it('should not throw when the browser has no User Timing API', () => { + configure({ defaultTimeoutMs: 0, userTimings: true }); + + expect(() => { + service.start({ name: 'load' }).step('one').complete(); + }).not.toThrow(); + expect(onlyScenarioRecord().status).toBe('success'); + }); + }); + + describe('settled$', () => { + it('should emit each scenario as it settles', () => { + const seen: string[] = []; + service.settled$.subscribe((r) => + seen.push(`${r.scenarioName}:${r.status}`) + ); + + service.start({ name: 'a' }).complete(); + service.start({ name: 'b' }).fail({ error: new Error('boom') }); + + expect(seen).toEqual(['a:success', 'b:failure']); + }); + + it('should not emit for a scenario still running', () => { + const seen: unknown[] = []; + service.settled$.subscribe((r) => seen.push(r)); + + service.start({ name: 'a' }).step('one'); + + expect(seen).toEqual([]); + }); + + it('should deliver the full record', () => { + let record: CpsScenarioRecord | undefined; + service.settled$.subscribe((r) => (record = r)); + + service.start({ name: 'a' }).step('one').complete(); + + expect(record).toMatchObject({ + scenarioName: 'a', + status: 'success' + }); + expect(record?.steps.map((s) => s.name)).toEqual([ + 'scenario-start', + 'one', + 'scenario-end' + ]); + }); + + it('should complete on destroy', () => { + let completed = false; + service.settled$.subscribe({ complete: () => (completed = true) }); + + service.ngOnDestroy(); + + expect(completed).toBe(true); + }); + + it('should not let a throwing subscriber break the scenario', () => { + // RxJS's Subject.next() already isolates a throwing subscriber, not + // cpsSafeVoid — this confirms the guarantee holds either way. + service.settled$.subscribe(() => { + throw new Error('subscriber exploded'); + }); + + expect(() => service.start({ name: 'a' }).complete()).not.toThrow(); + }); + + it("should not let a subscriber's mutation affect what the sink receives", () => { + service.settled$.subscribe((r) => { + (r as { status: string }).status = 'tampered'; + r.metadata = { tampered: true }; + }); + + service.start({ name: 'a' }).complete({ metadata: { real: true } }); + + expect( + sink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario)[0].payload + ).toMatchObject({ status: 'success', metadata: { real: true } }); + }); + }); + + describe('failure isolation', () => { + let consoleError: jest.SpyInstance; + + beforeEach(() => { + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withScenarios({ defaultTimeoutMs: 0 }) + ), + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + ThrowingSink, + { provide: CpsTelemetrySink, useExisting: ThrowingSink } + ] + }); + service = TestBed.inject(CpsScenarioTelemetryService); + }); + + afterEach(() => { + configure(); + consoleError.mockRestore(); + }); + + it('should never let a broken sink reach application code', () => { + expect(() => { + const scenario = service.start({ name: 'load' }); + scenario.step('one'); + scenario.complete(); + }).not.toThrow(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('failed'), + expect.any(Error) + ); + }); + + it('should still expose a usable scenario when the sink is broken', () => { + const scenario = service.start({ name: 'load' }); + expect(scenario.id).toBeTruthy(); + scenario.complete(); + expect(scenario.status).toBe('success'); + }); + + it('should never let a throwing metadata getter reach application code', () => { + const metadata: CpsTelemetryMetadata = {}; + Object.defineProperty(metadata, 'poison', { + enumerable: true, + get(): never { + throw new Error('metadata getter exploded'); + } + }); + + expect(() => service.start({ name: 'load', metadata })).not.toThrow(); + expect(consoleError).toHaveBeenCalledWith( + expect.stringContaining('failed'), + expect.any(Error) + ); + }); + }); + + describe('active scenario registry', () => { + it('should warn in dev mode when active scenarios exceed the threshold', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + for (let i = 0; i < 51; i++) { + service.start({ name: 'load', timeoutMs: 0 }); + } + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + '[cps-telemetry] High number of active scenarios (51)' + ) + ); + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/services/cps-scenario-telemetry.service/cps-scenario-telemetry.service.ts b/projects/cps-telemetry/src/lib/services/cps-scenario-telemetry.service/cps-scenario-telemetry.service.ts new file mode 100644 index 000000000..4bf3d0192 --- /dev/null +++ b/projects/cps-telemetry/src/lib/services/cps-scenario-telemetry.service/cps-scenario-telemetry.service.ts @@ -0,0 +1,248 @@ +import { DOCUMENT } from '@angular/common'; +import { inject, Injectable, OnDestroy } from '@angular/core'; +import { Observable, Subject } from 'rxjs'; +import { + CPS_REDACT_CONFIG, + CPS_TELEMETRY_IDENTITY +} from '../../config/cps-telemetry-common.config/cps-telemetry-common.config'; +import { CPS_SCENARIO_TELEMETRY_CONFIG } from '../../config/cps-scenario-telemetry.config/cps-scenario-telemetry.config'; +import { + CpsScenarioName, + CpsScenarioOptions, + CpsScenarioRecord +} from '../../models/cps-scenario.models/cps-scenario.models'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { + cpsDeepClone, + cpsIsBrowser, + cpsIsDevMode, + cpsSafeVoid +} from '../../utils/cps-telemetry-safe.util/cps-telemetry-safe.util'; +import { cpsRedactConfigFor } from '../../utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; +import { CpsScenario } from '../../scenario/cps-scenario/cps-scenario'; +import { CpsTelemetryMonitor } from '../cps-telemetry-monitor.service/cps-telemetry-monitor.service'; + +/** + * How many scenarios may be in flight before a development-mode warning + * suggests a leak. + * + * Concurrent scenarios are legitimate — several journeys, tabs or panels + * can run at once — so this is a smell threshold, not a limit: nothing is + * dropped or rejected when it is crossed. Set well above any plausible + * real concurrency, so crossing it means scenarios with `timeoutMs: 0` or + * long lifecycles are being started and never settled. + */ +const ACTIVE_SCENARIO_WARN_THRESHOLD = 50; + +/** + * Creates and tracks scenarios — user journeys whose health this telemetry + * layer measures. + * + * Each {@link start} call returns an independent {@link CpsScenario}; there is + * no "current" scenario, so overlapping journeys are tracked separately. + * Scenarios still in flight when the page unloads are settled as `abandoned`. + * + * @example + * ```typescript + * const scenario = scenarioTelemetry.start({ name: 'load-dashboard' }); + * try { + * scenario.step('fetch-widgets'); + * const widgets = await this.api.widgets(); + * scenario.complete({ metadata: { widgetCount: widgets.length } }); + * } catch (error) { + * scenario.fail({ error }); + * } + * ``` + * + * @group Services + */ +@Injectable({ providedIn: 'root' }) +export class CpsScenarioTelemetryService implements OnDestroy { + private readonly identity = inject(CPS_TELEMETRY_IDENTITY); + private readonly scenarioConfig = inject(CPS_SCENARIO_TELEMETRY_CONFIG); + private readonly redact = cpsRedactConfigFor( + inject(CPS_REDACT_CONFIG), + this.scenarioConfig.redact + ); + + private readonly sink = inject(CpsTelemetrySink); + private readonly monitor = inject(CpsTelemetryMonitor); + private readonly document = inject(DOCUMENT); + private readonly isBrowser = cpsIsBrowser(); + + private readonly active = new Map<string, CpsScenario>(); + private readonly onPageHide = () => this.flushActive(); + private readonly onVisibilityChange = () => { + if (this.document.visibilityState === 'hidden') { + this.flushSink(); + } + }; + + private readonly _settled$ = new Subject<CpsScenarioRecord>(); + + /** + * Emits every scenario record as it settles. + * + * Useful for reacting to outcomes without wrapping every call site — a + * debug overlay, a retry prompt, a test harness asserting on journeys. + * + * @example + * ```typescript + * scenarioTelemetry.settled$ + * .pipe(filter((r) => r.status === 'failure')) + * .subscribe((r) => this.notifications.warn(`${r.scenarioName} failed`)); + * ``` + */ + readonly settled$: Observable<CpsScenarioRecord> = + this._settled$.asObservable(); + + constructor() { + if (this.isBrowser) { + this.document.defaultView?.addEventListener('pagehide', this.onPageHide); + + this.document.addEventListener( + 'visibilitychange', + this.onVisibilityChange + ); + } + } + + /** + * Starts a new scenario. + * + * @param options the scenario name and optional classification, timeout and + * attributes + * @returns an independent scenario instance + */ + start(options: CpsScenarioOptions): CpsScenario { + const scenario = new CpsScenario(options, { + identity: this.identity, + scenarioConfig: this.scenarioConfig, + redact: this.redact, + sink: this.sink, + monitor: this.monitor, + onSettled: (scenarioId, record) => { + this.active.delete(scenarioId); + if (!this._settled$.observed) { + return; + } + // Cloned so a settled$ subscriber can't mutate the same object + // that's about to be (or already was) shipped to the sink. + cpsSafeVoid('scenarioTelemetry.notify', () => + this._settled$.next(cpsDeepClone(record)) + ); + } + }); + + cpsSafeVoid('scenarioTelemetry.register', () => { + this.active.set(scenario.id, scenario); + if (cpsIsDevMode() && this.active.size > ACTIVE_SCENARIO_WARN_THRESHOLD) { + // eslint-disable-next-line no-console + console.warn( + `[cps-telemetry] High number of active scenarios (${this.active.size}). Ensure scenarios with timeoutMs: 0 or long lifecycles are settled on destroy.` + ); + } + }); + + return scenario; + } + + /** + * Looks up a scenario that has not yet settled. + * + * @param scenarioId the id to look up + * @returns the scenario, or `undefined` if unknown or already settled + */ + find(scenarioId: string): CpsScenario | undefined { + return this.active.get(scenarioId); + } + + /** + * Every active scenario with the given name. + * + * `name` is a metric dimension, not a concurrency key - any number of + * scenarios can legitimately share one (two tabs, two dashboard panels, + * two independently-edited rows). Use this to inspect what's currently + * running under a name and decide for yourself, e.g. before starting + * another one. + * + * @param name the scenario name to match + * @returns matching active scenarios, in start order + */ + findByName(name: CpsScenarioName): CpsScenario[] { + return this.getActive().filter((scenario) => scenario.name === name); + } + + /** + * Looks up an active scenario by id, asserting it has the expected name. + * + * The same lookup as {@link find}, with an extra check that its `name` + * matches - a defensive assertion for a caller that already expects a + * specific scenario name at this id, rather than a bare id lookup. + * + * @param name the expected scenario name + * @param scenarioId the id to look up + * @returns the scenario, or `undefined` if not active or its name + * doesn't match + */ + findByNameAndId( + name: CpsScenarioName, + scenarioId: string + ): CpsScenario | undefined { + const scenario = this.find(scenarioId); + return scenario?.name === name ? scenario : undefined; + } + + /** + * Every scenario currently in flight. + * + * @returns the active scenarios, in start order + */ + getActive(): CpsScenario[] { + return [...this.active.values()]; + } + + /** @inheritdoc */ + ngOnDestroy(): void { + if (this.isBrowser) { + this.document.defaultView?.removeEventListener( + 'pagehide', + this.onPageHide + ); + this.document.removeEventListener( + 'visibilitychange', + this.onVisibilityChange + ); + } + this.flushActive(); + this._settled$.complete(); + } + + /** + * Dispatches what the sink holds, leaving running scenarios alone. + * + * Uses the beacon transport since the page may not survive a normal request. + */ + private flushSink(): void { + cpsSafeVoid('scenarioTelemetry.flushSink', () => this.sink.flush(true)); + } + + /** + * Settles every in-flight scenario as abandoned and asks the sink to send + * what it holds using a transport that survives unload. + * + * Scenario emission is synchronous, so everything settled here reaches the + * sink before the beacon goes out. + */ + private flushActive(): void { + cpsSafeVoid('scenarioTelemetry.flush', () => { + for (const scenario of this.getActive()) { + scenario.settle('abandoned', { + reason: 'page-hidden', + metadata: { abandonedBy: 'page-hidden' } + }); + } + this.sink.flush(true); + }); + } +} diff --git a/projects/cps-telemetry/src/lib/services/cps-telemetry-monitor.service/cps-telemetry-monitor.integration.spec.ts b/projects/cps-telemetry/src/lib/services/cps-telemetry-monitor.service/cps-telemetry-monitor.integration.spec.ts new file mode 100644 index 000000000..f14d1c661 --- /dev/null +++ b/projects/cps-telemetry/src/lib/services/cps-telemetry-monitor.service/cps-telemetry-monitor.integration.spec.ts @@ -0,0 +1,210 @@ +import { Injectable } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + provideCpsTelemetry, + withLogging, + withScenarios +} from '../../providers/cps-telemetry-common.providers/cps-telemetry-common.providers'; +import { + CPS_LOG_API_PROVIDER, + CpsLogApiProvider +} from '../../providers/cps-log-api.provider/cps-log-api.provider'; +import { CpsLogRecord } from '../../models/cps-log.models/cps-log.models'; +import { + CpsTelemetryError, + CpsTelemetryMetadata +} from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { CpsTelemetryObservedEvent } from '../../models/cps-telemetry-monitor.models/cps-telemetry-monitor.models'; +import { CpsTelemetrySink } from '../../sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { CpsBiTelemetryService } from '../cps-bi-telemetry.service/cps-bi-telemetry.service'; +import { CpsLoggerService } from '../cps-logger.service/cps-logger.service'; +import { CpsScenarioTelemetryService } from '../cps-scenario-telemetry.service/cps-scenario-telemetry.service'; +import { CpsTelemetryMonitor } from './cps-telemetry-monitor.service'; + +/** Counts every hand-off, so a test can prove nothing is sent twice. */ +@Injectable() +class CountingSink extends CpsTelemetrySink { + readonly records: { eventType: string; payload: object }[] = []; + readonly errors: CpsTelemetryError[] = []; + throwOnRecord = false; + + record(eventType: string, payload: object, _m?: CpsTelemetryMetadata): void { + if (this.throwOnRecord) { + throw new Error('sink is down'); + } + this.records.push({ eventType, payload }); + } + + recordError(error: CpsTelemetryError): void { + this.errors.push(error); + } + + getSessionId(): string | undefined { + return 'session-1'; + } + + setUserId(): void {} + + getUserId(): string | undefined { + return undefined; + } + + flush(): void {} +} + +@Injectable() +class CountingLogApi implements CpsLogApiProvider { + readonly sent: CpsLogRecord[] = []; + throwOnSend = false; + + send(record: CpsLogRecord): void { + if (this.throwOnSend) { + throw new Error('log backend is down'); + } + this.sent.push(record); + } + + query(): Promise<CpsLogRecord[]> { + return Promise.resolve([]); + } +} + +describe('CpsTelemetryMonitor at the hand-off sites', () => { + let observed: CpsTelemetryObservedEvent[]; + let sink: CountingSink; + let logApi: CountingLogApi; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withScenarios({ defaultTimeoutMs: 0, emitLifecycleEvents: true }), + withLogging({ mirrorErrorsToRum: true }) + ), + CountingSink, + { provide: CpsTelemetrySink, useExisting: CountingSink }, + CountingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: CountingLogApi } + ] + }); + sink = TestBed.inject(CountingSink); + logApi = TestBed.inject(CountingLogApi); + observed = []; + TestBed.inject(CpsTelemetryMonitor).events$.subscribe((e) => + observed.push(e) + ); + }); + + afterEach(() => jest.restoreAllMocks()); + + it('should observe a BI event once, and send it once', () => { + TestBed.inject(CpsBiTelemetryService).track('export_clicked', { + format: 'csv' + }); + + expect(sink.records).toHaveLength(1); + expect(observed).toHaveLength(1); + expect(observed[0]).toMatchObject({ + kind: 'bi', + eventType: 'com.cps.bi', + destination: 'sink', + origin: { forwarded: false }, + payload: { eventName: 'export_clicked', metadata: { format: 'csv' } } + }); + }); + + it('should observe each scenario step and the settled record, once each', () => { + TestBed.inject(CpsScenarioTelemetryService) + .start({ name: 'load' }) + .step('one') + .step('two') + .complete(); + + expect(observed.map((e) => e.kind)).toEqual([ + 'scenario-step', + 'scenario-step', + 'scenario' + ]); + expect(observed.map((e) => 'eventType' in e && e.eventType)).toEqual([ + 'com.cps.scenario.step', + 'com.cps.scenario.step', + 'com.cps.scenario' + ]); + + expect(sink.records).toHaveLength(observed.length); + expect(observed[2].payload).toEqual(sink.records[2].payload); + }); + + it('should observe a log record as handed to the log provider', () => { + TestBed.inject(CpsLoggerService).getLogger('checkout').warn('careful'); + + expect(logApi.sent).toHaveLength(1); + expect(observed).toHaveLength(1); + expect(observed[0]).toMatchObject({ + kind: 'log', + destination: 'log-provider', + payload: { logger: 'checkout', level: 'warn', message: 'careful' } + }); + }); + + it('should observe a mirrored error, linked to the log line it came from', () => { + TestBed.inject(CpsLoggerService) + .getLogger('checkout') + .error('failed', { error: new Error('boom') }); + + expect(sink.errors).toHaveLength(1); + const [log, error] = observed; + expect(log.kind).toBe('log'); + expect(error).toMatchObject({ + kind: 'error', + destination: 'sink', + relatedSequence: log.sequence, + payload: { name: 'Error', message: 'boom' } + }); + }); + + it('should observe nothing for a hand-off that threw', () => { + sink.throwOnRecord = true; + logApi.throwOnSend = true; + + TestBed.inject(CpsBiTelemetryService).track('export_clicked'); + TestBed.inject(CpsLoggerService).getLogger('checkout').log('lost'); + + expect(observed).toHaveLength(0); + }); + + it('should send exactly the same number of events whether observed or not', () => { + const run = () => { + TestBed.inject(CpsBiTelemetryService).track('a'); + TestBed.inject(CpsScenarioTelemetryService) + .start({ name: 'load' }) + .step('one') + .complete(); + TestBed.inject(CpsLoggerService).getLogger('checkout').log('hi'); + }; + + run(); + const observedCounts = [sink.records.length, logApi.sent.length]; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideCpsTelemetry( + { application: 'test-app', environment: 'test', version: '1.0.0' }, + withScenarios({ defaultTimeoutMs: 0, emitLifecycleEvents: true }) + ), + CountingSink, + { provide: CpsTelemetrySink, useExisting: CountingSink }, + CountingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: CountingLogApi } + ] + }); + sink = TestBed.inject(CountingSink); + logApi = TestBed.inject(CountingLogApi); + run(); + + expect([sink.records.length, logApi.sent.length]).toEqual(observedCounts); + }); +}); diff --git a/projects/cps-telemetry/src/lib/services/cps-telemetry-monitor.service/cps-telemetry-monitor.service.spec.ts b/projects/cps-telemetry/src/lib/services/cps-telemetry-monitor.service/cps-telemetry-monitor.service.spec.ts new file mode 100644 index 000000000..c319bee8d --- /dev/null +++ b/projects/cps-telemetry/src/lib/services/cps-telemetry-monitor.service/cps-telemetry-monitor.service.spec.ts @@ -0,0 +1,118 @@ +import { isDevMode } from '@angular/core'; +import { CpsBiEvent } from '../../models/cps-bi.models/cps-bi.models'; +import { CpsTelemetryObservedEvent } from '../../models/cps-telemetry-monitor.models/cps-telemetry-monitor.models'; +import * as safeUtil from '../../utils/cps-telemetry-safe.util/cps-telemetry-safe.util'; +import { CpsTelemetryMonitor } from './cps-telemetry-monitor.service'; + +jest.mock('@angular/core', () => ({ + ...jest.requireActual('@angular/core'), + isDevMode: jest.fn(() => false) +})); + +function biEvent(): CpsBiEvent { + return { + eventName: 'export_clicked', + eventTime: '2026-01-01T00:00:00.000Z', + metadata: { format: 'csv' }, + application: 'test-app' + }; +} + +describe('CpsTelemetryMonitor', () => { + let monitor: CpsTelemetryMonitor; + let received: CpsTelemetryObservedEvent[]; + + beforeEach(() => { + monitor = new CpsTelemetryMonitor(); + received = []; + }); + + afterEach(() => { + monitor.ngOnDestroy(); + jest.restoreAllMocks(); + }); + + function publishBi(payload = biEvent()): number | undefined { + return monitor.publish({ + kind: 'bi', + eventType: 'com.cps.bi', + payload, + destination: 'sink', + origin: { forwarded: false } + }); + } + + it('should do nothing, and copy nothing, while nobody is subscribed', () => { + const clone = jest.spyOn(safeUtil, 'cpsDeepClone'); + + expect(monitor.observed).toBe(false); + expect(publishBi()).toBeUndefined(); + expect(clone).not.toHaveBeenCalled(); + }); + + it('should deliver an event to a subscriber, with the envelope filled in', () => { + monitor.events$.subscribe((e) => received.push(e)); + + const sequence = publishBi(); + + expect(received).toHaveLength(1); + expect(received[0]).toMatchObject({ + kind: 'bi', + eventType: 'com.cps.bi', + destination: 'sink', + origin: { forwarded: false }, + sequence + }); + expect(received[0].capturedAt).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ + ); + }); + + it('should hand a subscriber a copy, isolated from the object that was sent', () => { + monitor.events$.subscribe((e) => received.push(e)); + const sent = biEvent(); + + publishBi(sent); + const observed = received[0].payload as CpsBiEvent; + + expect(observed).toEqual(sent); + expect(observed).not.toBe(sent); + + observed.metadata!.format = 'changed-by-observer'; + sent.metadata!.format = 'changed-by-sender'; + expect(sent.metadata!.format).toBe('changed-by-sender'); + expect(observed.metadata!.format).toBe('changed-by-observer'); + }); + + it('should number events in increasing order', () => { + monitor.events$.subscribe((e) => received.push(e)); + + publishBi(); + publishBi(); + publishBi(); + + const [a, b, c] = received.map((e) => e.sequence); + expect(b).toBe(a + 1); + expect(c).toBe(b + 1); + }); + + it('should stop observing once the last subscriber leaves', () => { + const subscription = monitor.events$.subscribe((e) => received.push(e)); + subscription.unsubscribe(); + + expect(monitor.observed).toBe(false); + expect(publishBi()).toBeUndefined(); + expect(received).toHaveLength(0); + }); + + it('should never throw into the hand-off site, even when copying fails', () => { + (isDevMode as jest.Mock).mockReturnValue(false); + monitor.events$.subscribe((e) => received.push(e)); + jest.spyOn(safeUtil, 'cpsDeepClone').mockImplementation(() => { + throw new Error('clone failed'); + }); + + expect(() => publishBi()).not.toThrow(); + expect(received).toHaveLength(0); + }); +}); diff --git a/projects/cps-telemetry/src/lib/services/cps-telemetry-monitor.service/cps-telemetry-monitor.service.ts b/projects/cps-telemetry/src/lib/services/cps-telemetry-monitor.service/cps-telemetry-monitor.service.ts new file mode 100644 index 000000000..81d67462e --- /dev/null +++ b/projects/cps-telemetry/src/lib/services/cps-telemetry-monitor.service/cps-telemetry-monitor.service.ts @@ -0,0 +1,81 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import { Observable, Subject } from 'rxjs'; +import { + CpsTelemetryObservedEvent, + CpsTelemetryPublishInput +} from '../../models/cps-telemetry-monitor.models/cps-telemetry-monitor.models'; +import { + cpsDeepClone, + cpsSafeVoid +} from '../../utils/cps-telemetry-safe.util/cps-telemetry-safe.util'; + +/** + * A read-only view of every event this library hands to a destination — + * the telemetry sink or the application's log API provider. + * + * Each hand-off site publishes here immediately *after* the hand-off, with + * the very object it just sent. Nothing is sent from here: this is an + * in-memory stream for observers such as the diagnostics popup, never a + * second delivery path, so observing it cannot cause an event to be sent + * twice. + * + * Costs one check per event while nobody subscribes. A subscriber receives + * a deep copy of each payload, so it can neither change what was sent nor + * see a live object change after the fact. + * + * "Handed over" is not "delivered": a sink may still drop an event, for + * example when RUM samples a session out or reaches its event limit. + * + * @example + * ```typescript + * inject(CpsTelemetryMonitor) + * .events$.pipe(filter((e) => e.kind === 'scenario')) + * .subscribe((e) => console.table(e.payload)); + * ``` + * + * @group Services + */ +@Injectable({ providedIn: 'root' }) +export class CpsTelemetryMonitor implements OnDestroy { + private readonly subject = new Subject<CpsTelemetryObservedEvent>(); + private sequence = 0; + + /** Every observed event, from the moment of subscription onward. */ + readonly events$: Observable<CpsTelemetryObservedEvent> = + this.subject.asObservable(); + + /** Whether anything is currently subscribed to {@link events$}. */ + get observed(): boolean { + return this.subject.observed; + } + + /** + * Records one hand-off. Called by the library's own hand-off sites; an + * application has no reason to call it. + * + * @param input the event just handed over, and where it went + * @returns the sequence number assigned, or `undefined` when nobody is + * subscribed and the event was not recorded + */ + publish(input: CpsTelemetryPublishInput): number | undefined { + if (!this.subject.observed) { + return undefined; + } + + const sequence = ++this.sequence; + cpsSafeVoid('monitor.publish', () => { + this.subject.next({ + ...input, + payload: cpsDeepClone(input.payload), + sequence, + capturedAt: new Date().toISOString() + } as CpsTelemetryObservedEvent); + }); + return sequence; + } + + /** @inheritdoc */ + ngOnDestroy(): void { + this.subject.complete(); + } +} diff --git a/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast-host.service.ts b/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast-host.service.ts new file mode 100644 index 000000000..9ec8f6721 --- /dev/null +++ b/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast-host.service.ts @@ -0,0 +1,410 @@ +import { inject, Injectable, OnDestroy } from '@angular/core'; +import { + cpsSafe, + cpsSafeVoid, + cpsSafeVoidMaybeAsync +} from '../../utils/cps-telemetry-safe.util/cps-telemetry-safe.util'; +import { + CpsBroadcastConnection, + cpsConnectBroadcastChannel, + cpsElectBroadcastHostLeader, + cpsIsBroadcastMessage +} from './cps-broadcast.messages'; +import { CpsTelemetrySink } from '../cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { CpsBroadcastTelemetrySink } from './cps-broadcast-telemetry.sink'; +import { CpsBroadcastLogApiProvider } from '../../providers/cps-broadcast-log-api.provider/cps-broadcast-log-api.provider'; +import { CpsTelemetryMonitor } from '../../services/cps-telemetry-monitor.service/cps-telemetry-monitor.service'; +import { + CpsJsonObject, + CpsTelemetryEventOrigin +} from '../../models/cps-telemetry-monitor.models/cps-telemetry-monitor.models'; +import { CpsLogRecord } from '../../models/cps-log.models/cps-log.models'; +import { + CpsTelemetryError, + CpsTelemetryMetadata +} from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { + CPS_LOG_API_PROVIDER, + CpsLogQuery +} from '../../providers/cps-log-api.provider/cps-log-api.provider'; + +/** + * Active host count per channel, scoped to this JS realm — distinguishes a + * true duplicate provider from another tab sharing the same channel. + */ +const hostsInThisRealm = new Map<string, number>(); + +/** + * A forwarded error-level log record, kept for exactly one message in case + * the next is its `mirrorErrorsToRum` copy. + */ +interface CpsForwardedErrorLog { + application: string; + expected: CpsTelemetryError; + sequence: number; +} + +/** + * Receives telemetry forwarded by follower realms and records it through this + * realm's sink. + * + * Runs in the shell, the realm with the real sink. Fragments using + * {@link CpsBroadcastTelemetrySink} post their events here, so one AWS client, + * one session and one event budget serve the whole composed page. + * + * Fragments' log records arrive the same way, from + * {@link CpsBroadcastLogApiProvider}, and go to this realm's + * {@link CPS_LOG_API_PROVIDER} — which also answers their `query()` calls. + * The provider is required: a shell hosting fragments without one fails at + * injection, rather than silently discarding their logs. + * + * A realm that forwards its own telemetry — `provideCpsTelemetrySink('broadcast')` + * — cannot also host it: the host would receive its own realm's messages + * and forward them again, forever. Such a host warns and stays inactive. + * + * A Web Locks-based election ({@link cpsElectBroadcastHostLeader}) keeps + * exactly one host active per channel; others stay passive. + * + * @example + * ```typescript + * import { provideCpsTelemetryRumSink } from 'cps-telemetry/rum'; + * + * providers: [ + * provideCpsTelemetry({ application: 'shell', environment: 'prod', version: '1.0.0' }), + * provideCpsTelemetryRumSink(), + * { provide: CPS_LOG_API_PROVIDER, useExisting: MyLogBackend }, + * provideCpsTelemetryBroadcastHost() + * ] + * ``` + * + * @group Services + */ +@Injectable() +export class CpsTelemetryBroadcastHost implements OnDestroy { + private readonly sink = inject(CpsTelemetrySink); + private readonly monitor = inject(CpsTelemetryMonitor); + private readonly logApi = inject(CPS_LOG_API_PROVIDER); + private readonly connection: CpsBroadcastConnection = + cpsConnectBroadcastChannel('broadcastHost'); + + /** Number of messages accepted, for tests and diagnostics. */ + private _received = 0; + + /** + * The session and user id last sent in an `identity` message. + * + * Compared against the sink's current values on every follower activity, so + * a late-resolving session id or user id gets announced once it exists. + */ + private lastAnnouncedSessionId?: string; + private lastAnnouncedUserId?: string; + + /** Whether this realm won the leader election; non-leaders stay passive. */ + private isLeader = false; + private releaseLeadership: () => void = () => undefined; + + /** The last message, when it was an error-level log record. */ + private lastErrorLog?: CpsForwardedErrorLog; + + constructor() { + this.warnIfDuplicateInThisRealm(); + if (this.forwardsItsOwnRealm()) { + this.connection.close(); + return; + } + + this.connection.onMessage((data) => this.onMessage(data)); + this.releaseLeadership = cpsElectBroadcastHostLeader( + this.connection.channelName, + () => { + this.isLeader = true; + this.announceIdentity(); + } + ); + } + + /** How many follower messages have been accepted. */ + get received(): number { + return this._received; + } + + /** @inheritdoc */ + ngOnDestroy(): void { + this.forgetInThisRealm(); + this.releaseLeadership(); + this.connection.close(); + } + + private onMessage(data: unknown): void { + cpsSafeVoid('broadcastHost.receive', () => { + if (!this.isLeader || !cpsIsBroadcastMessage(data)) { + return; + } + + if ( + data.kind !== 'identity' && + data.kind !== 'identity-request' && + data.kind !== 'log-query-result' + ) { + this._received++; + } + + // A mirror arrives straight after its log record, so the candidate + // lives for one message only. + const errorLog = this.lastErrorLog; + this.lastErrorLog = undefined; + + switch (data.kind) { + case 'event': + this.sink.record(data.eventType, data.payload, data.metadata); + this.monitor.publish({ + ...classifyForwarded(data.eventType), + eventType: data.eventType, + payload: data.payload as CpsJsonObject, + destination: 'sink', + origin: forwardedFrom(data.metadata?.application) + }); + this.reannounceIfIdentityChanged(); + break; + case 'error': + this.sink.recordError(data.error, data.metadata); + this.monitor.publish({ + kind: 'error', + payload: data.error, + ...(errorLog && + isMirrorOf(errorLog, data.error, data.metadata) && { + relatedSequence: errorLog.sequence + }), + destination: 'sink', + origin: forwardedFrom(data.metadata?.application) + }); + this.reannounceIfIdentityChanged(); + break; + case 'user': + this.sink.setUserId(data.userId); + this.announceIdentity(); + break; + case 'flush': + this.sink.flush(data.beacon); + this.reannounceIfIdentityChanged(); + break; + case 'identity-request': + this.announceIdentity(); + break; + case 'identity': + // Another realm's host on this origin-wide channel — expected, + // not a problem. Same-document duplicates are caught by + // warnIfDuplicateInThisRealm() instead. + break; + case 'log': + this.deliverLog(data.record); + this.reannounceIfIdentityChanged(); + break; + case 'log-flush': + cpsSafeVoidMaybeAsync('broadcastHost.logFlush', () => + this.logApi?.flush?.() + ); + break; + case 'log-query': + this.answerLogQuery(data.id, data.filter); + break; + case 'log-query-result': + // Hosts answer queries; they never ask them. + break; + } + }); + } + + /** + * Hands a follower's record to this realm's log API provider, and tells + * the monitor once it has — as {@link CpsLoggerService} does for its own. + */ + private deliverLog(record: CpsLogRecord): void { + cpsSafeVoidMaybeAsync('broadcastHost.log', () => { + const pending = this.logApi.send(record); + const sequence = this.monitor.publish({ + kind: 'log', + payload: record, + destination: 'log-provider', + origin: forwardedFrom(record.application) + }); + if (record.level === 'error' && sequence !== undefined) { + this.lastErrorLog = { + application: record.application, + // What CpsLoggerService mirrors to RUM for this record. + expected: record.error ?? { name: 'Error', message: record.message }, + sequence + }; + } + return pending; + }); + } + + /** + * Answers a follower's query from this realm's log API provider. Always + * answers — `[]` when there is no provider or it fails — so the follower + * isn't left waiting for its timeout. + */ + private answerLogQuery(id: string, filter: CpsLogQuery): void { + const reply = (records: unknown) => + this.connection.post({ + kind: 'log-query-result', + id, + records: Array.isArray(records) ? records : [] + }); + + const pending = cpsSafe( + 'broadcastHost.logQuery', + () => this.logApi.query(filter), + undefined + ); + + if (!pending) { + reply([]); + return; + } + // Promise.resolve: a provider breaking its contract with a plain value + // still gets an answer out. + Promise.resolve(pending).then(reply, () => reply([])); + } + + /** + * Whether this realm's own sink or log provider forwards over the + * channel. Hosting then would loop: the host receives its own realm's + * messages and hands them straight back to the channel. + */ + private forwardsItsOwnRealm(): boolean { + const forwards = + this.sink instanceof CpsBroadcastTelemetrySink || + this.logApi instanceof CpsBroadcastLogApiProvider; + + if (forwards) { + cpsSafeVoid('broadcastHost.forwardingRealmWarning', () => { + // eslint-disable-next-line no-console + console.warn( + `[cps-telemetry] this realm forwards its own telemetry on channel "${this.connection.channelName}", so it cannot also host it; the host stays inactive. Provide the host only in the realm with the real sink and log provider` + ); + }); + } + return forwards; + } + + /** + * Warns when another host is already active on the same channel in this + * realm — a real misconfiguration. Not based on the `identity` broadcast, + * which can't distinguish this from a different, legitimate tab. + */ + private warnIfDuplicateInThisRealm(): void { + const channelName = this.connection.channelName; + const activeCount = hostsInThisRealm.get(channelName) ?? 0; + + if (activeCount > 0) { + cpsSafeVoid('broadcastHost.duplicateWarning', () => { + // eslint-disable-next-line no-console + console.warn( + `[cps-telemetry] a second telemetry host is active on channel "${channelName}" in this document; only one realm should provide it` + ); + }); + } + + hostsInThisRealm.set(channelName, activeCount + 1); + } + + /** Releases this host's slot in {@link hostsInThisRealm}. */ + private forgetInThisRealm(): void { + const channelName = this.connection.channelName; + const activeCount = hostsInThisRealm.get(channelName) ?? 0; + + if (activeCount <= 1) { + hostsInThisRealm.delete(channelName); + } else { + hostsInThisRealm.set(channelName, activeCount - 1); + } + } + + private announceIdentity(): void { + const sessionId = this.currentSessionId(); + const userId = this.currentUserId(); + this.lastAnnouncedSessionId = sessionId; + this.lastAnnouncedUserId = userId; + this.connection.post({ kind: 'identity', sessionId, userId }); + } + + /** + * Re-announces identity when the sink's session id or user id has moved on + * from what followers were last told. + */ + private reannounceIfIdentityChanged(): void { + if ( + this.currentSessionId() !== this.lastAnnouncedSessionId || + this.currentUserId() !== this.lastAnnouncedUserId + ) { + this.announceIdentity(); + } + } + + private currentSessionId(): string | undefined { + return cpsSafe( + 'broadcastHost.getSessionId', + () => this.sink.getSessionId(), + undefined + ); + } + + private currentUserId(): string | undefined { + return cpsSafe( + 'broadcastHost.getUserId', + () => this.sink.getUserId(), + undefined + ); + } +} + +/** + * Which kind a forwarded event is, from its type's suffix. Matched on the + * suffix rather than this realm's namespace, since each realm configures + * its own `eventNamespace`. + */ +function classifyForwarded(eventType: string): { + kind: 'scenario-step' | 'scenario' | 'bi' | 'unknown'; +} { + if (eventType.endsWith('.scenario.step')) { + return { kind: 'scenario-step' }; + } + if (eventType.endsWith('.scenario')) { + return { kind: 'scenario' }; + } + if (eventType.endsWith('.bi')) { + return { kind: 'bi' }; + } + return { kind: 'unknown' }; +} + +/** + * Whether a forwarded error is the `mirrorErrorsToRum` copy of the error-level + * log record just before it: same realm, same name and message. A mirror + * the logger had to rewrite simply goes unlinked. + */ +function isMirrorOf( + errorLog: CpsForwardedErrorLog, + error: CpsTelemetryError, + metadata: CpsTelemetryMetadata | undefined +): boolean { + return ( + metadata?.application === errorLog.application && + error.name === errorLog.expected.name && + error.message === errorLog.expected.message + ); +} + +/** + * The forwarding realm: the `application` a follower sink stamps onto + * metadata, or a log record's own. + */ +function forwardedFrom( + application: CpsTelemetryMetadata[string] | undefined +): CpsTelemetryEventOrigin { + return typeof application === 'string' + ? { forwarded: true, application } + : { forwarded: true }; +} diff --git a/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast-telemetry.sink.ts b/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast-telemetry.sink.ts new file mode 100644 index 000000000..5453b5815 --- /dev/null +++ b/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast-telemetry.sink.ts @@ -0,0 +1,133 @@ +import { inject, Injectable, OnDestroy } from '@angular/core'; +import { + CpsTelemetryError, + CpsTelemetryMetadata +} from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { CPS_TELEMETRY_IDENTITY } from '../../config/cps-telemetry-common.config/cps-telemetry-common.config'; +import { cpsSafeVoid } from '../../utils/cps-telemetry-safe.util/cps-telemetry-safe.util'; +import { + CpsBroadcastConnection, + cpsConnectBroadcastChannel, + cpsIsBroadcastMessage +} from './cps-broadcast.messages'; +import { CpsTelemetrySink } from '../cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; + +/** + * Telemetry sink for a follower realm — a micro-frontend fragment, a webview + * panel, any context with its own JavaScript realm but the same origin. + * + * Creates no AWS client of its own. Events are forwarded over a + * `BroadcastChannel` to the realm running + * {@link provideCpsTelemetryBroadcastHost}, which records them through the one + * real sink. Application code sees no difference — it injects the same + * services and calls the same methods. + * + * @example + * ```typescript + * providers: [ + * provideCpsTelemetry({ application: 'cart', environment: 'prod', version: '1.0.0' }), + * provideCpsTelemetrySink('broadcast') + * ] + * ``` + * + * @group Services + */ +@Injectable() +export class CpsBroadcastTelemetrySink + extends CpsTelemetrySink + implements OnDestroy +{ + private readonly connection: CpsBroadcastConnection = + cpsConnectBroadcastChannel('broadcastSink'); + + /** The host's session id, once it has answered. */ + private sessionId?: string; + private userId?: string; + + private readonly config = inject(CPS_TELEMETRY_IDENTITY); + + /** + * This realm's own identity, stamped onto everything it forwards. + * + * The host records through its own RUM client, so without this, forwarded + * events would all be labelled as the host's realm. + */ + private readonly origin = { + application: this.config.application, + environment: this.config.environment, + appVersion: this.config.version + }; + + constructor() { + super(); + + this.connection.onMessage((data) => this.onMessage(data)); + this.connection.post({ kind: 'identity-request' }); + } + + /** @inheritdoc */ + record( + eventType: string, + payload: object, + metadata?: CpsTelemetryMetadata + ): void { + this.connection.post({ + kind: 'event', + eventType, + payload, + metadata: { ...metadata, ...this.origin } + }); + } + + /** @inheritdoc */ + recordError(error: CpsTelemetryError, metadata?: CpsTelemetryMetadata): void { + this.connection.post({ + kind: 'error', + error, + metadata: { ...metadata, ...this.origin } + }); + } + + /** + * The host's session id. + * + * Returns `undefined` until the host answers. Log records written before + * that carry no session id but are still correlated by `scenarioId`. + * + * @returns the shared session id, when known + */ + getSessionId(): string | undefined { + return this.sessionId; + } + + /** @inheritdoc */ + setUserId(userId: string | undefined): void { + this.userId = userId; + this.connection.post({ kind: 'user', userId }); + } + + /** @inheritdoc */ + getUserId(): string | undefined { + return this.userId; + } + + /** @inheritdoc */ + flush(beacon = false): void { + this.connection.post({ kind: 'flush', beacon }); + } + + /** @inheritdoc */ + ngOnDestroy(): void { + this.connection.close(); + } + + private onMessage(data: unknown): void { + cpsSafeVoid('broadcastSink.receive', () => { + if (!cpsIsBroadcastMessage(data) || data.kind !== 'identity') { + return; + } + this.sessionId = data.sessionId; + this.userId = data.userId; + }); + } +} diff --git a/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast.messages.ts b/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast.messages.ts new file mode 100644 index 000000000..62ae86ce4 --- /dev/null +++ b/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast.messages.ts @@ -0,0 +1,386 @@ +import { inject, InjectionToken } from '@angular/core'; +import { + CpsTelemetryError, + CpsTelemetryMetadata +} from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { + CPS_LOG_LEVEL_ORDER, + CpsLogRecord +} from '../../models/cps-log.models/cps-log.models'; +import { CpsLogQuery } from '../../providers/cps-log-api.provider/cps-log-api.provider'; +import { + cpsIsBrowser, + cpsSafeVoid +} from '../../utils/cps-telemetry-safe.util/cps-telemetry-safe.util'; + +/** + * Default `BroadcastChannel` name used between telemetry realms. + * + * @group Utils + */ +export const CPS_DEFAULT_BROADCAST_CHANNEL = 'cps-telemetry'; + +/** + * Overrides the channel name shared by the host and its followers. + * + * Both sides must agree. Change it only to run two independent telemetry + * networks on one origin. + * + * @group Tokens + */ +export const CPS_BROADCAST_CHANNEL = new InjectionToken<string>( + 'CPS_BROADCAST_CHANNEL' +); + +/** + * Everything that travels between telemetry realms. + * + * Structured-cloned by `BroadcastChannel`, so every payload must be plain + * data. + */ +export type CpsBroadcastMessage = + | { + kind: 'event'; + eventType: string; + payload: object; + metadata?: CpsTelemetryMetadata; + } + | { + kind: 'error'; + error: CpsTelemetryError; + metadata?: CpsTelemetryMetadata; + } + | { kind: 'user'; userId: string | undefined } + | { kind: 'flush'; beacon: boolean } + | { kind: 'identity-request' } + /** The host announcing shared identity. Always sends both fields, even when only one changed. */ + | { kind: 'identity'; sessionId?: string; userId?: string } + /** A follower's log record, for the host to hand to its log API provider. */ + | { kind: 'log'; record: CpsLogRecord } + /** A follower asking the host to flush its log API provider. */ + | { kind: 'log-flush' } + /** A follower reading records back through the host's log API provider. */ + | { kind: 'log-query'; id: string; filter: CpsLogQuery } + /** + * The host's answer to one `log-query`, matched by `id`. The records come + * from the host's backend unchecked; the asking follower keeps only those + * that pass {@link cpsIsBroadcastLogRecord}. + */ + | { kind: 'log-query-result'; id: string; records: unknown[] }; + +/** Every `kind` the union above accepts, for {@link cpsIsBroadcastMessage}. */ +const MESSAGE_KINDS: ReadonlySet<CpsBroadcastMessage['kind']> = new Set([ + 'event', + 'error', + 'user', + 'flush', + 'identity-request', + 'identity', + 'log', + 'log-flush', + 'log-query', + 'log-query-result' +] as const); + +const LOG_LEVELS: ReadonlySet<string> = new Set( + Object.keys(CPS_LOG_LEVEL_ORDER) +); + +/** Minimal `BroadcastChannel` surface this library relies on. */ +export interface CpsBroadcastChannelLike { + postMessage(message: unknown): void; + close(): void; + onmessage: ((event: { data: unknown }) => void) | null; +} + +/** + * Opens a broadcast channel, when the browser provides the API. + * + * Feature-detected: unavailable under jsdom (tests) and in server-side + * rendering. + */ +export function cpsOpenBroadcastChannel( + name: string +): CpsBroadcastChannelLike | undefined { + const Channel = ( + globalThis as { + BroadcastChannel?: new (name: string) => CpsBroadcastChannelLike; + } + ).BroadcastChannel; + + if (typeof Channel !== 'function') { + return undefined; + } + + try { + return new Channel(name); + } catch { + return undefined; + } +} + +/** Minimal Web Locks API surface this library relies on. */ +interface CpsLockManagerLike { + request(name: string, callback: () => Promise<void>): Promise<void>; +} + +/** + * Elects exactly one leader among same-origin realms on this channel, via + * the Web Locks API — prevents two shell tabs from both recording every + * forwarded message. Fails open (elects immediately) when Web Locks is + * unavailable. + * + * @returns releases the lock so the next queued realm can become leader. + */ +export function cpsElectBroadcastHostLeader( + channelName: string, + onElected: () => void +): () => void { + const locks = (globalThis as { navigator?: { locks?: CpsLockManagerLike } }) + .navigator?.locks; + + if (!locks) { + onElected(); + return () => undefined; + } + + let released = false; + let release: () => void = () => { + released = true; + }; + + const failOpen = () => { + if (!released) { + onElected(); + } + }; + + try { + locks + .request( + `cps-telemetry-host:${channelName}`, + () => + new Promise<void>((resolve) => { + if (released) { + resolve(); + return; + } + release = () => { + released = true; + resolve(); + }; + onElected(); + }) + ) + .catch(failOpen); + } catch { + failOpen(); + } + + return () => release(); +} + +/** A realm's live connection to the shared broadcast channel. */ +export interface CpsBroadcastConnection { + /** The channel name actually in use — the injected override, or the default. */ + readonly channelName: string; + + /** Whether a channel is open — false without `BroadcastChannel`, on the server, and once closed. */ + isOpen(): boolean; + + /** Sends a message to every other realm on this channel. No-op if unavailable. */ + post(message: CpsBroadcastMessage): void; + + /** Registers the handler for incoming messages. No-op if the channel could not be opened. */ + onMessage(handler: (data: unknown) => void): void; + + /** Closes the channel and stops any further delivery. Safe to call more than once. */ + close(): void; +} + +/** + * Opens this realm's connection to the shared broadcast channel: resolves the + * channel name, feature-detects `BroadcastChannel`, and wraps every operation + * in the library's fail-open guard. + * + * Must be called from an injection context (a field initializer or a + * constructor). + */ +export function cpsConnectBroadcastChannel( + operation: string +): CpsBroadcastConnection { + const channelName = + inject(CPS_BROADCAST_CHANNEL, { optional: true }) ?? + CPS_DEFAULT_BROADCAST_CHANNEL; + let channel = cpsIsBrowser() + ? cpsOpenBroadcastChannel(channelName) + : undefined; + + return { + channelName, + isOpen() { + return channel !== undefined; + }, + post(message) { + cpsSafeVoid(`${operation}.post`, () => channel?.postMessage(message)); + }, + onMessage(handler) { + if (channel) { + channel.onmessage = (event) => handler(event.data); + } + }, + close() { + cpsSafeVoid(`${operation}.close`, () => { + channel?.close(); + channel = undefined; + }); + } + }; +} + +/** + * Whether `value` is a valid `metadata` field: absent, or a non-array + * object whose values are all finite numbers, strings, booleans, or null — + * the same flat-primitives contract `cpsRedactMetadata` enforces on the way + * out. Without this, a same-origin sender (not necessarily this library's + * own sink) could post a nested object on the channel that reaches a + * receiving sink's own sanitizer — e.g. `CpsRumTelemetrySink.sanitize()` — + * unchecked, since that step trusts the shape rather than re-validating it. + */ +function isValidBroadcastMetadata(value: unknown): boolean { + if (value === undefined) { + return true; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + return Object.values(value).every( + (v) => + v === null || + typeof v === 'string' || + typeof v === 'boolean' || + (typeof v === 'number' && Number.isFinite(v)) + ); +} + +function isOptionalString(value: unknown): boolean { + return value === undefined || typeof value === 'string'; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isValidTelemetryError(value: unknown): boolean { + return ( + isRecord(value) && + typeof value.name === 'string' && + typeof value.message === 'string' && + isOptionalString(value.stack) + ); +} + +function isLogLevel(value: unknown): boolean { + return typeof value === 'string' && LOG_LEVELS.has(value); +} + +/** + * Whether `value` has the shape of a {@link CpsLogRecord}: the required + * strings, a known level, optional strings where the record has them, and + * metadata and error held to the same rules as on an `event` or `error` + * message. + */ +export function cpsIsBroadcastLogRecord(value: unknown): value is CpsLogRecord { + return ( + isRecord(value) && + typeof value.timestamp === 'string' && + isLogLevel(value.level) && + typeof value.message === 'string' && + typeof value.application === 'string' && + typeof value.environment === 'string' && + typeof value.version === 'string' && + isOptionalString(value.logger) && + isOptionalString(value.context) && + isOptionalString(value.correlationId) && + isOptionalString(value.userId) && + isOptionalString(value.sessionId) && + isValidBroadcastMetadata(value.metadata) && + (value.error === undefined || isValidTelemetryError(value.error)) + ); +} + +function isValidLogQuery(value: unknown): boolean { + return ( + isRecord(value) && + isOptionalString(value.correlationId) && + isOptionalString(value.logger) && + (value.minLevel === undefined || isLogLevel(value.minLevel)) && + isOptionalString(value.from) && + isOptionalString(value.to) && + (value.limit === undefined || + (typeof value.limit === 'number' && Number.isFinite(value.limit))) + ); +} + +/** + * Narrows an incoming `BroadcastChannel` payload to a telemetry message. + * + * Checks each kind's required fields, not just `kind`, so a same-named + * message from something else on the channel is rejected. + */ +export function cpsIsBroadcastMessage( + data: unknown +): data is CpsBroadcastMessage { + if (typeof data !== 'object' || data === null) { + return false; + } + + const message = data as Record<string, unknown>; + const kind = message.kind as CpsBroadcastMessage['kind']; + if (!MESSAGE_KINDS.has(kind)) { + return false; + } + + switch (kind) { + case 'event': + return ( + typeof message.eventType === 'string' && + typeof message.payload === 'object' && + message.payload !== null && + isValidBroadcastMetadata(message.metadata) + ); + case 'error': + return ( + isValidTelemetryError(message.error) && + isValidBroadcastMetadata(message.metadata) + ); + case 'flush': + return typeof message.beacon === 'boolean'; + case 'user': + // Absent or `undefined` userId both mean "clear it". + return message.userId === undefined || typeof message.userId === 'string'; + case 'identity': + return ( + (message.sessionId === undefined || + typeof message.sessionId === 'string') && + (message.userId === undefined || typeof message.userId === 'string') + ); + case 'identity-request': + case 'log-flush': + return true; + case 'log': + return cpsIsBroadcastLogRecord(message.record); + case 'log-query': + return ( + typeof message.id === 'string' && + message.id !== '' && + isValidLogQuery(message.filter) + ); + case 'log-query-result': + return ( + typeof message.id === 'string' && + message.id !== '' && + Array.isArray(message.records) + ); + } +} diff --git a/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast.spec.ts b/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast.spec.ts new file mode 100644 index 000000000..548bd31b3 --- /dev/null +++ b/projects/cps-telemetry/src/lib/sinks/cps-broadcast/cps-broadcast.spec.ts @@ -0,0 +1,1528 @@ +import { + CPS_TELEMETRY_EVENT_TYPE, + CpsTelemetryError, + CpsTelemetryMetadata +} from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { CpsLogRecord } from '../../models/cps-log.models/cps-log.models'; +import { DOCUMENT } from '@angular/common'; +import { Injectable, Injector, PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { CpsLoggerService } from '../../services/cps-logger.service/cps-logger.service'; +import { + CPS_DEFAULT_TELEMETRY_CONFIG, + CPS_REDACT_CONFIG, + CPS_TELEMETRY_IDENTITY +} from '../../config/cps-telemetry-common.config/cps-telemetry-common.config'; +import { CPS_LOG_CONFIG } from '../../config/cps-log.config/cps-log.config'; +import { CPS_SCENARIO_TELEMETRY_CONFIG } from '../../config/cps-scenario-telemetry.config/cps-scenario-telemetry.config'; +import { CpsScenarioTelemetryService } from '../../services/cps-scenario-telemetry.service/cps-scenario-telemetry.service'; +import { + CPS_LOG_API_PROVIDER, + CpsLogApiProvider, + CpsLogQuery +} from '../../providers/cps-log-api.provider/cps-log-api.provider'; +import { CpsTelemetryBroadcastHost } from './cps-broadcast-host.service'; +import { CpsBroadcastTelemetrySink } from './cps-broadcast-telemetry.sink'; +import { + CPS_BROADCAST_LOG_QUERY_TIMEOUT_MS, + CpsBroadcastLogApiProvider +} from '../../providers/cps-broadcast-log-api.provider/cps-broadcast-log-api.provider'; +import { + CPS_BROADCAST_CHANNEL, + CPS_DEFAULT_BROADCAST_CHANNEL, + cpsConnectBroadcastChannel, + cpsElectBroadcastHostLeader, + cpsIsBroadcastMessage +} from './cps-broadcast.messages'; +import { CpsTelemetrySink } from '../cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +import { CpsTelemetryMonitor } from '../../services/cps-telemetry-monitor.service/cps-telemetry-monitor.service'; +import { CpsTelemetryObservedEvent } from '../../models/cps-telemetry-monitor.models/cps-telemetry-monitor.models'; + +/** + * Each realm gets its own injector — the shell and every fragment run in a + * separate JavaScript context and share no Angular injector. + */ +function createRealm(providers: unknown[]): Injector { + return Injector.create({ + providers: [ + CpsTelemetryMonitor, + { provide: PLATFORM_ID, useValue: 'browser' }, + { provide: DOCUMENT, useValue: document }, + { + provide: CPS_TELEMETRY_IDENTITY, + useValue: { + application: 'realm', + environment: 'test', + version: '1.0.0' + } + }, + { + provide: CPS_SCENARIO_TELEMETRY_CONFIG, + useValue: { + ...CPS_DEFAULT_TELEMETRY_CONFIG.scenario, + defaultTimeoutMs: 0 + } + }, + { provide: CPS_LOG_CONFIG, useValue: CPS_DEFAULT_TELEMETRY_CONFIG.logs }, + { + provide: CPS_REDACT_CONFIG, + useValue: CPS_DEFAULT_TELEMETRY_CONFIG.redact + }, + RecordingLogApi, + { provide: CPS_LOG_API_PROVIDER, useExisting: RecordingLogApi }, + CpsLoggerService, + CpsScenarioTelemetryService, + ...(providers as never[]) + ] + }); +} + +/** + * In-memory stand-in for `BroadcastChannel`, since jsdom implements none. + * + * Delivers messages between instances sharing a name to every other + * instance, asynchronously, matching the real API. + */ +export class CpsBroadcastChannelStub { + private static channels = new Map<string, CpsBroadcastChannelStub[]>(); + + onmessage: ((event: { data: unknown }) => void) | null = null; + + closed = false; + + constructor(readonly name: string) { + const peers = CpsBroadcastChannelStub.channels.get(name) ?? []; + peers.push(this); + CpsBroadcastChannelStub.channels.set(name, peers); + } + + /** Installs the stub as the global `BroadcastChannel`. */ + static install(): void { + Object.defineProperty(globalThis, 'BroadcastChannel', { + value: CpsBroadcastChannelStub, + configurable: true, + writable: true + }); + } + + /** + * Removes the global and forgets every channel. + * + * Closes every known channel first, so no delivery scheduled before + * teardown reaches a listener from a previous test. + */ + static uninstall(): void { + delete (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel; + + for (const peers of CpsBroadcastChannelStub.channels.values()) { + peers.forEach((peer) => (peer.closed = true)); + } + CpsBroadcastChannelStub.channels.clear(); + } + + /** + * Runs pending deliveries until the channel is quiet. + * + * Several turns, since a request/response exchange takes a task in each + * direction and a handler may post again. + */ + static async settle(turns = 5): Promise<void> { + for (let i = 0; i < turns; i++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + postMessage(message: unknown): void { + if (this.closed) { + return; + } + + const peers = CpsBroadcastChannelStub.channels.get(this.name) ?? []; + const data = JSON.parse(JSON.stringify(message)); + + for (const peer of peers) { + if (peer === this) { + continue; + } + setTimeout(() => { + if (!peer.closed) { + peer.onmessage?.({ data }); + } + }, 0); + } + } + + close(): void { + this.closed = true; + const peers = CpsBroadcastChannelStub.channels.get(this.name) ?? []; + CpsBroadcastChannelStub.channels.set( + this.name, + peers.filter((peer) => peer !== this) + ); + } +} + +/** Minimal Web Locks API stub: grants each named lock to one requester at a time. */ +class LockManagerStub { + private readonly held = new Set<string>(); + private readonly queues = new Map<string, Array<() => void>>(); + + request(name: string, callback: () => Promise<void>): Promise<void> { + return new Promise((resolve) => { + const grant = () => { + this.held.add(name); + callback().then(() => { + this.held.delete(name); + resolve(); + this.queues.get(name)?.shift()?.(); + }); + }; + + if (this.held.has(name)) { + const queue = this.queues.get(name) ?? []; + queue.push(grant); + this.queues.set(name, queue); + } else { + grant(); + } + }); + } + + /** Installs the stub as `navigator.locks`. */ + static install(): void { + Object.defineProperty(globalThis.navigator, 'locks', { + value: new LockManagerStub(), + configurable: true + }); + } + + static uninstall(): void { + delete (globalThis.navigator as { locks?: unknown }).locks; + } +} + +/** Captures what the library emitted, so a test can assert on it. */ +@Injectable() +class RecordingSink extends CpsTelemetrySink { + readonly events: { + eventType: string; + payload: Record<string, unknown>; + metadata?: CpsTelemetryMetadata; + }[] = []; + + readonly errors: CpsTelemetryError[] = []; + readonly errorMetadata: (CpsTelemetryMetadata | undefined)[] = []; + readonly flushes: boolean[] = []; + userId?: string; + sessionId: string | undefined = 'test-session'; + + record( + eventType: string, + payload: object, + metadata?: CpsTelemetryMetadata + ): void { + this.events.push({ + eventType, + payload: payload as Record<string, unknown>, + metadata + }); + } + + recordError(error: CpsTelemetryError, metadata?: CpsTelemetryMetadata): void { + this.errors.push(error); + this.errorMetadata.push(metadata); + } + + getSessionId(): string | undefined { + return this.sessionId; + } + + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + getUserId(): string | undefined { + return this.userId; + } + + flush(beacon = false): void { + this.flushes.push(beacon); + } + + ofType(eventType: string) { + return this.events.filter((event) => event.eventType === eventType); + } +} + +/** Keeps every batch, so a test can assert on what was shipped. */ +@Injectable() +class RecordingLogApi implements CpsLogApiProvider { + readonly records: CpsLogRecord[] = []; + flushes = 0; + + flush(): void { + this.flushes++; + } + + send(record: CpsLogRecord): void { + this.records.push(record); + } + + query(filter: CpsLogQuery): Promise<CpsLogRecord[]> { + let found = this.records; + if (filter.correlationId) { + found = found.filter((r) => r.correlationId === filter.correlationId); + } + if (filter.logger) { + found = found.filter((r) => r.logger === filter.logger); + } + if (filter.limit !== undefined) { + found = found.slice(0, filter.limit); + } + return Promise.resolve(found); + } +} + +describe('broadcast telemetry across realms', () => { + let shellSink: RecordingSink; + let shell: Injector; + let host: CpsTelemetryBroadcastHost; + + beforeEach(() => { + CpsBroadcastChannelStub.install(); + TestBed.resetTestingModule(); + + shell = createRealm([ + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink }, + CpsTelemetryBroadcastHost + ]); + shellSink = shell.get(RecordingSink); + host = shell.get(CpsTelemetryBroadcastHost); + }); + + afterEach(() => { + host.ngOnDestroy(); + CpsBroadcastChannelStub.uninstall(); + jest.restoreAllMocks(); + }); + + /** + * Builds a follower realm wired as `provideCpsTelemetrySink('broadcast')` + * wires one: a forwarding sink and a forwarding log provider. + */ + function createFragment(): Injector { + const realm = createRealm([ + CpsBroadcastTelemetrySink, + { provide: CpsTelemetrySink, useExisting: CpsBroadcastTelemetrySink }, + CpsBroadcastLogApiProvider, + { provide: CPS_LOG_API_PROVIDER, useExisting: CpsBroadcastLogApiProvider } + ]); + // Resolve eagerly: Injector.create is lazy, and the sink's constructor + // opens the channel and requests identity. + realm.get(CpsTelemetrySink); + realm.get(CPS_LOG_API_PROVIDER); + return realm; + } + + /** A record as a log backend would hand it back. */ + function storedRecord(fields: Partial<CpsLogRecord> = {}): CpsLogRecord { + return { + timestamp: '2026-01-01T00:00:00.000Z', + level: 'log', + message: 'stored', + application: 'realm', + environment: 'test', + version: '1.0.0', + ...fields + }; + } + + describe('forwarding', () => { + it('should record a fragment event through the shell sink', async () => { + const fragment = createFragment(); + fragment.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'x' }); + + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.events).toEqual([ + expect.objectContaining({ + eventType: 'com.cps.bi', + payload: { eventName: 'x' } + }) + ]); + }); + + it('should attribute the event to the realm that emitted it', async () => { + const fragment = createFragment(); + fragment.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'x' }); + + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.events[0].metadata).toMatchObject({ + application: 'realm', + environment: 'test', + appVersion: '1.0.0' + }); + }); + + it('should carry event metadata', async () => { + const fragment = createFragment(); + fragment + .get(CpsTelemetrySink) + .record('com.cps.bi', { eventName: 'x' }, { feature: 'cart' }); + + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.events[0].metadata).toMatchObject({ feature: 'cart' }); + }); + + it('should show a forwarded event to the shell monitor, marked with its realm', async () => { + const observed: CpsTelemetryObservedEvent[] = []; + shell.get(CpsTelemetryMonitor).events$.subscribe((e) => observed.push(e)); + const fragment = createFragment(); + + fragment + .get(CpsTelemetrySink) + .record('com.cps.scenario.step', { scenarioName: 'load' }); + fragment.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'x' }); + fragment.get(CpsTelemetrySink).record('custom.type', { a: 1 }); + fragment + .get(CpsTelemetrySink) + .recordError({ name: 'TypeError', message: 'boom' }); + await CpsBroadcastChannelStub.settle(); + + expect(observed.map((e) => e.kind)).toEqual([ + 'scenario-step', + 'bi', + 'unknown', + 'error' + ]); + for (const e of observed) { + expect(e.origin).toEqual({ forwarded: true, application: 'realm' }); + expect(e.destination).toBe('sink'); + } + expect(shellSink.events).toHaveLength(3); + expect(shellSink.errors).toHaveLength(1); + }); + + it('should show a fragment scenario once per monitor — local in the fragment, forwarded in the shell', async () => { + const fragment = createFragment(); + const inShell: CpsTelemetryObservedEvent[] = []; + const inFragment: CpsTelemetryObservedEvent[] = []; + shell.get(CpsTelemetryMonitor).events$.subscribe((e) => inShell.push(e)); + fragment + .get(CpsTelemetryMonitor) + .events$.subscribe((e) => inFragment.push(e)); + + fragment + .get(CpsScenarioTelemetryService) + .start({ name: 'load' }) + .complete(); + await CpsBroadcastChannelStub.settle(); + + expect(fragment.get(CpsTelemetryMonitor)).not.toBe( + shell.get(CpsTelemetryMonitor) + ); + expect(inFragment).toHaveLength(1); + expect(inFragment[0]).toMatchObject({ + kind: 'scenario', + origin: { forwarded: false } + }); + expect(inShell).toHaveLength(1); + expect(inShell[0]).toMatchObject({ + kind: 'scenario', + origin: { forwarded: true, application: 'realm' } + }); + + expect( + shellSink.events.filter((e) => e.eventType === 'com.cps.scenario') + ).toHaveLength(1); + }); + + it('should forward handled errors', async () => { + const fragment = createFragment(); + fragment + .get(CpsTelemetrySink) + .recordError({ name: 'TypeError', message: 'boom' }); + + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.errors).toEqual([ + { name: 'TypeError', message: 'boom' } + ]); + }); + + it('should attribute a forwarded error to the realm that recorded it', async () => { + const fragment = createFragment(); + fragment + .get(CpsTelemetrySink) + .recordError({ name: 'TypeError', message: 'boom' }); + + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.errorMetadata[0]).toMatchObject({ + application: 'realm', + environment: 'test', + appVersion: '1.0.0' + }); + }); + + it('should forward flush requests, preserving the beacon flag', async () => { + const fragment = createFragment(); + fragment.get(CpsTelemetrySink).flush(true); + + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.flushes).toEqual([true]); + }); + + it('should forward a user id so one identity covers every realm', async () => { + const fragment = createFragment(); + fragment.get(CpsTelemetrySink).setUserId('user-42'); + + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.userId).toBe('user-42'); + }); + + it('should keep several fragments independent but pointed at one sink', async () => { + const a = createFragment(); + const b = createFragment(); + + a.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'from-a' }); + b.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'from-b' }); + + await CpsBroadcastChannelStub.settle(); + + expect( + shellSink.events.map( + (e) => (e.payload as { eventName: string }).eventName + ) + ).toEqual(['from-a', 'from-b']); + }); + + it('should not create an AWS client in the fragment realm', () => { + const fragment = createFragment(); + expect(fragment.get(CpsTelemetrySink)).toBeInstanceOf( + CpsBroadcastTelemetrySink + ); + }); + }); + + describe('shared session identity', () => { + it('should adopt the shell session id', async () => { + shellSink.sessionId = 'shell-session-1'; + const fragment = createFragment(); + + await CpsBroadcastChannelStub.settle(); + + expect(fragment.get(CpsTelemetrySink).getSessionId()).toBe( + 'shell-session-1' + ); + }); + + it('should report no session id before the shell answers', () => { + const fragment = createFragment(); + expect(fragment.get(CpsTelemetrySink).getSessionId()).toBeUndefined(); + }); + + it('should reach a fragment that started before the shell host existed', async () => { + host.ngOnDestroy(); + + const fragment = createFragment(); + await CpsBroadcastChannelStub.settle(); + expect(fragment.get(CpsTelemetrySink).getSessionId()).toBeUndefined(); + + const lateShell = createRealm([ + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink }, + CpsTelemetryBroadcastHost + ]); + lateShell.get(RecordingSink).sessionId = 'late-session'; + host = lateShell.get(CpsTelemetryBroadcastHost); + + await CpsBroadcastChannelStub.settle(); + + expect(fragment.get(CpsTelemetrySink).getSessionId()).toBe( + 'late-session' + ); + }); + + it('should re-announce once a session id that was not ready at construction resolves', async () => { + shellSink.sessionId = undefined; + const fragment = createFragment(); + await CpsBroadcastChannelStub.settle(); + expect(fragment.get(CpsTelemetrySink).getSessionId()).toBeUndefined(); + + shellSink.sessionId = 'shell-session-1'; + + fragment.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'x' }); + await CpsBroadcastChannelStub.settle(); + + expect(fragment.get(CpsTelemetrySink).getSessionId()).toBe( + 'shell-session-1' + ); + }); + + it('should propagate a user id set in one fragment to a sibling fragment', async () => { + const a = createFragment(); + const b = createFragment(); + await CpsBroadcastChannelStub.settle(); + + a.get(CpsTelemetrySink).setUserId('user-42'); + await CpsBroadcastChannelStub.settle(); + + expect(b.get(CpsTelemetrySink).getUserId()).toBe('user-42'); + }); + + it("should clear a sibling fragment's user id on sign-out", async () => { + const a = createFragment(); + const b = createFragment(); + await CpsBroadcastChannelStub.settle(); + + a.get(CpsTelemetrySink).setUserId('user-42'); + await CpsBroadcastChannelStub.settle(); + expect(b.get(CpsTelemetrySink).getUserId()).toBe('user-42'); + + a.get(CpsTelemetrySink).setUserId(undefined); + await CpsBroadcastChannelStub.settle(); + + expect(b.get(CpsTelemetrySink).getUserId()).toBeUndefined(); + }); + + it("should sign out every realm when a fragment sets ''", async () => { + const a = createFragment(); + const b = createFragment(); + await CpsBroadcastChannelStub.settle(); + + a.get(CpsTelemetrySink).setUserId('user-42'); + await CpsBroadcastChannelStub.settle(); + + a.get(CpsTelemetrySink).setUserId(''); + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.userId).toBe(''); + expect(b.get(CpsTelemetrySink).getUserId()).toBe(''); + }); + + it('should announce a user id set directly on the shell, not only one relayed from a fragment', async () => { + const fragment = createFragment(); + await CpsBroadcastChannelStub.settle(); + + shellSink.userId = 'shell-user'; + fragment.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'x' }); + await CpsBroadcastChannelStub.settle(); + + expect(fragment.get(CpsTelemetrySink).getUserId()).toBe('shell-user'); + }); + }); + + describe('scenarios in a fragment', () => { + it('should emit one packed record through the shell', async () => { + const fragment = createFragment(); + const scenarioTelemetry = fragment.get(CpsScenarioTelemetryService); + + const scenario = scenarioTelemetry.start({ name: 'add-to-cart' }); + scenario.step('validate').step('submit'); + scenario.complete(); + + await CpsBroadcastChannelStub.settle(); + + const records = shellSink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario); + expect(records).toHaveLength(1); + expect(records[0].payload).toMatchObject({ + scenarioName: 'add-to-cart', + status: 'success' + }); + }); + + it('should let a fragment scenario name a shell scenario as its parent', async () => { + const fragment = createFragment(); + const scenarioTelemetry = fragment.get(CpsScenarioTelemetryService); + + scenarioTelemetry + .start({ name: 'add-to-cart', parentScenarioId: 'shell-scenario-1' }) + .complete(); + + await CpsBroadcastChannelStub.settle(); + + expect( + shellSink.ofType(CPS_TELEMETRY_EVENT_TYPE.scenario)[0].payload + ).toMatchObject({ parentScenarioId: 'shell-scenario-1' }); + }); + }); + + describe('logs from a fragment', () => { + it('should ship a fragment log record through the shell log provider, and only there', async () => { + const fragment = createFragment(); + fragment + .get(CpsLoggerService) + .getLogger('cart') + .warn('Careful now', { correlationId: 'c-1' }); + + await CpsBroadcastChannelStub.settle(); + + expect(shell.get(RecordingLogApi).records).toEqual([ + expect.objectContaining({ + level: 'warn', + message: 'Careful now', + logger: 'cart', + correlationId: 'c-1', + application: 'realm' + }) + ]); + // The fragment's realm has a backend bound too; it must stay unused. + expect(fragment.get(RecordingLogApi).records).toHaveLength(0); + }); + + it('should show a forwarded record once per monitor — local in the fragment, forwarded in the shell', async () => { + const fragment = createFragment(); + const inShell: CpsTelemetryObservedEvent[] = []; + const inFragment: CpsTelemetryObservedEvent[] = []; + shell.get(CpsTelemetryMonitor).events$.subscribe((e) => inShell.push(e)); + fragment + .get(CpsTelemetryMonitor) + .events$.subscribe((e) => inFragment.push(e)); + + fragment.get(CpsLoggerService).getLogger('cart').log('Hello'); + await CpsBroadcastChannelStub.settle(); + + expect(inFragment).toEqual([ + expect.objectContaining({ + kind: 'log', + destination: 'log-provider', + origin: { forwarded: false } + }) + ]); + expect(inShell).toEqual([ + expect.objectContaining({ + kind: 'log', + destination: 'log-provider', + origin: { forwarded: true, application: 'realm' }, + payload: expect.objectContaining({ message: 'Hello' }) + }) + ]); + }); + + it('should forward a flush to the shell log provider', async () => { + const fragment = createFragment(); + fragment.get(CPS_LOG_API_PROVIDER).flush?.(); + + await CpsBroadcastChannelStub.settle(); + + expect(shell.get(RecordingLogApi).flushes).toBe(1); + }); + + it('should answer a fragment query from the shell log provider', async () => { + const shellLogs = shell.get(RecordingLogApi); + shellLogs.send(storedRecord({ correlationId: 'c-9', message: 'mine' })); + shellLogs.send(storedRecord({ correlationId: 'c-other' })); + const fragment = createFragment(); + + const pending = fragment + .get(CpsLoggerService) + .query({ correlationId: 'c-9' }); + await CpsBroadcastChannelStub.settle(); + + await expect(pending).resolves.toEqual([ + expect.objectContaining({ correlationId: 'c-9', message: 'mine' }) + ]); + }); + + it('should resolve a query to [] when the shell provider rejects', async () => { + jest + .spyOn(shell.get(RecordingLogApi), 'query') + .mockRejectedValue(new Error('backend down')); + const fragment = createFragment(); + + const pending = fragment.get(CpsLoggerService).query({}); + await CpsBroadcastChannelStub.settle(); + + await expect(pending).resolves.toEqual([]); + }); + + it('should keep only well-formed records from the answer', async () => { + jest + .spyOn(shell.get(RecordingLogApi), 'query') + .mockResolvedValue([ + storedRecord({ message: 'good' }), + { junk: true } as unknown as CpsLogRecord + ]); + const fragment = createFragment(); + + const pending = fragment.get(CpsLoggerService).query({}); + await CpsBroadcastChannelStub.settle(); + + await expect(pending).resolves.toEqual([ + expect.objectContaining({ message: 'good' }) + ]); + }); + + it('should resolve a query to [] once no host has answered in time', async () => { + jest.useFakeTimers(); + try { + host.ngOnDestroy(); + const fragment = createFragment(); + let result: CpsLogRecord[] | undefined; + fragment + .get(CpsLoggerService) + .query({}) + .then((records) => (result = records)); + + await jest.advanceTimersByTimeAsync( + CPS_BROADCAST_LOG_QUERY_TIMEOUT_MS - 1 + ); + expect(result).toBeUndefined(); + + await jest.advanceTimersByTimeAsync(1); + expect(result).toEqual([]); + } finally { + jest.useRealTimers(); + } + }); + + it('should resolve unanswered queries to [] when the provider is destroyed', async () => { + host.ngOnDestroy(); + const provider = createFragment().get(CpsBroadcastLogApiProvider); + + const pending = provider.query({}); + provider.ngOnDestroy(); + + await expect(pending).resolves.toEqual([]); + }); + + it('should fail at injection when the shell binds no log provider', () => { + const bareShell = Injector.create({ + providers: [ + CpsTelemetryMonitor, + { provide: PLATFORM_ID, useValue: 'browser' }, + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink }, + CpsTelemetryBroadcastHost + ] + }); + + expect(() => bareShell.get(CpsTelemetryBroadcastHost)).toThrow( + /CPS_LOG_API_PROVIDER/ + ); + }); + + it('should link a mirrored error to its forwarded log record in the shell monitor', async () => { + const fragment = createRealm([ + CpsBroadcastTelemetrySink, + { provide: CpsTelemetrySink, useExisting: CpsBroadcastTelemetrySink }, + CpsBroadcastLogApiProvider, + { + provide: CPS_LOG_API_PROVIDER, + useExisting: CpsBroadcastLogApiProvider + }, + { + provide: CPS_LOG_CONFIG, + useValue: { + ...CPS_DEFAULT_TELEMETRY_CONFIG.logs, + mirrorErrorsToRum: true + } + } + ]); + const inShell: CpsTelemetryObservedEvent[] = []; + shell.get(CpsTelemetryMonitor).events$.subscribe((e) => inShell.push(e)); + + const logger = fragment.get(CpsLoggerService).getLogger('cart'); + logger.error('Payment failed'); + logger.error('Card declined', { error: new TypeError('declined') }); + await CpsBroadcastChannelStub.settle(); + + expect(inShell.map((e) => e.kind)).toEqual([ + 'log', + 'error', + 'log', + 'error' + ]); + const [firstLog, firstMirror, secondLog, secondMirror] = inShell; + expect(firstMirror).toMatchObject({ + payload: { name: 'Error', message: 'Payment failed' }, + relatedSequence: firstLog.sequence + }); + expect(secondMirror).toMatchObject({ + payload: { name: 'TypeError', message: 'declined' }, + relatedSequence: secondLog.sequence + }); + }); + + it('should not link an error that is not the mirror of the record before it', async () => { + const fragment = createFragment(); + const inShell: CpsTelemetryObservedEvent[] = []; + shell.get(CpsTelemetryMonitor).events$.subscribe((e) => inShell.push(e)); + + fragment.get(CpsLoggerService).getLogger('cart').error('Payment failed'); + fragment + .get(CpsTelemetrySink) + .recordError({ name: 'Error', message: 'Something else' }); + await CpsBroadcastChannelStub.settle(); + + const error = inShell.find((e) => e.kind === 'error'); + expect(error).toBeDefined(); + expect(error).not.toHaveProperty('relatedSequence'); + }); + }); + + describe('robustness', () => { + it('should keep a host inactive in a realm that forwards its own telemetry, instead of looping', async () => { + host.ngOnDestroy(); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const posts = jest.spyOn( + CpsBroadcastChannelStub.prototype, + 'postMessage' + ); + const misconfigured = createRealm([ + CpsBroadcastTelemetrySink, + { provide: CpsTelemetrySink, useExisting: CpsBroadcastTelemetrySink }, + CpsBroadcastLogApiProvider, + { + provide: CPS_LOG_API_PROVIDER, + useExisting: CpsBroadcastLogApiProvider + }, + CpsTelemetryBroadcastHost + ]); + host = misconfigured.get(CpsTelemetryBroadcastHost); + + misconfigured.get(CpsLoggerService).getLogger('cart').log('once'); + misconfigured + .get(CpsTelemetrySink) + .record('com.cps.bi', { eventName: 'once' }); + await CpsBroadcastChannelStub.settle(10); + + const kinds = posts.mock.calls.map( + ([message]) => (message as { kind: string }).kind + ); + expect(kinds.filter((k) => k === 'log')).toHaveLength(1); + expect(kinds.filter((k) => k === 'event')).toHaveLength(1); + expect(host.received).toBe(0); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('cannot also host it') + ); + }); + + it('should ignore unrelated traffic on the channel', async () => { + createFragment(); + const noise = new CpsBroadcastChannelStub('cps-telemetry'); + noise.postMessage({ some: 'other library' }); + + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.events).toHaveLength(0); + expect(host.received).toBe(0); + }); + + it('should warn when a second host claims the same channel in this realm', () => { + const channel = 'duplicate-detection-test'; + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const providers = [ + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink }, + { provide: CPS_BROADCAST_CHANNEL, useValue: channel } + ]; + const first = createRealm([...providers, CpsTelemetryBroadcastHost]).get( + CpsTelemetryBroadcastHost + ); + + expect(warn).not.toHaveBeenCalled(); + + createRealm([...providers, CpsTelemetryBroadcastHost]).get( + CpsTelemetryBroadcastHost + ); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('a second telemetry host is active') + ); + + first.ngOnDestroy(); + }); + + it('should not warn again for a fresh host once the only prior one on that channel was destroyed', () => { + const channel = 'duplicate-detection-recreate-test'; + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + const providers = [ + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink }, + { provide: CPS_BROADCAST_CHANNEL, useValue: channel } + ]; + const first = createRealm([...providers, CpsTelemetryBroadcastHost]).get( + CpsTelemetryBroadcastHost + ); + first.ngOnDestroy(); + + createRealm([...providers, CpsTelemetryBroadcastHost]).get( + CpsTelemetryBroadcastHost + ); + + expect(warn).not.toHaveBeenCalled(); + }); + + it('should not let a throwing console.warn crash duplicate-host construction', () => { + const channel = 'duplicate-detection-throwing-warn-test'; + const warn = jest.spyOn(console, 'warn').mockImplementation(() => { + throw new Error('console is patched and broken'); + }); + + const providers = [ + RecordingSink, + { provide: CpsTelemetrySink, useExisting: RecordingSink }, + { provide: CPS_BROADCAST_CHANNEL, useValue: channel } + ]; + const first = createRealm([...providers, CpsTelemetryBroadcastHost]).get( + CpsTelemetryBroadcastHost + ); + + let second: CpsTelemetryBroadcastHost | undefined; + expect(() => { + second = createRealm([...providers, CpsTelemetryBroadcastHost]).get( + CpsTelemetryBroadcastHost + ); + }).not.toThrow(); + + first.ngOnDestroy(); + second?.ngOnDestroy(); + warn.mockRestore(); + + const freshWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + createRealm([...providers, CpsTelemetryBroadcastHost]).get( + CpsTelemetryBroadcastHost + ); + expect(freshWarn).not.toHaveBeenCalled(); + freshWarn.mockRestore(); + }); + + it('should stop delivering once the fragment is destroyed', async () => { + const fragment = createFragment(); + const sink = fragment.get(CpsBroadcastTelemetrySink); + + sink.ngOnDestroy(); + sink.record('com.cps.bi', { eventName: 'after-destroy' }); + + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.events).toHaveLength(0); + }); + + it('should stop recording once the host is destroyed', async () => { + const fragment = createFragment(); + host.ngOnDestroy(); + + fragment.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'x' }); + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.events).toHaveLength(0); + }); + + it('should use a separate channel when one is configured', async () => { + const isolated = createRealm([ + CpsBroadcastTelemetrySink, + { provide: CpsTelemetrySink, useExisting: CpsBroadcastTelemetrySink }, + { provide: CPS_BROADCAST_CHANNEL, useValue: 'other-network' } + ]); + + isolated.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'x' }); + await CpsBroadcastChannelStub.settle(); + + expect(shellSink.events).toHaveLength(0); + }); + }); + + describe('leader election', () => { + let consoleWarn: jest.SpyInstance; + let createdHosts: CpsTelemetryBroadcastHost[]; + + beforeEach(() => { + host.ngOnDestroy(); + LockManagerStub.install(); + consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + createdHosts = []; + }); + + afterEach(() => { + createdHosts.forEach((h) => h.ngOnDestroy()); + LockManagerStub.uninstall(); + consoleWarn.mockRestore(); + }); + + function createHost(): { + host: CpsTelemetryBroadcastHost; + sink: RecordingSink; + logs: RecordingLogApi; + } { + const sink = new RecordingSink(); + const realm = createRealm([ + { provide: RecordingSink, useValue: sink }, + { provide: CpsTelemetrySink, useExisting: RecordingSink }, + CpsTelemetryBroadcastHost + ]); + const realmHost = realm.get(CpsTelemetryBroadcastHost); + createdHosts.push(realmHost); + return { host: realmHost, sink, logs: realm.get(RecordingLogApi) }; + } + + it('should keep a second host passive while the first is still leader', async () => { + const first = createHost(); + const second = createHost(); + + const fragment = createFragment(); + fragment.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'x' }); + await CpsBroadcastChannelStub.settle(); + + expect(first.sink.events).toHaveLength(1); + expect(second.sink.events).toHaveLength(0); + expect(second.host.received).toBe(0); + }); + + it('should leave log records and queries to the leader', async () => { + const first = createHost(); + const second = createHost(); + const fragment = createFragment(); + + fragment.get(CpsLoggerService).getLogger('cart').log('Hello'); + const pending = fragment.get(CpsLoggerService).query({}); + await CpsBroadcastChannelStub.settle(); + + expect(first.logs.records).toHaveLength(1); + expect(second.logs.records).toHaveLength(0); + expect(second.host.received).toBe(0); + await expect(pending).resolves.toHaveLength(1); + }); + + it('should hand off leadership once the leader is destroyed', async () => { + const first = createHost(); + const second = createHost(); + + first.host.ngOnDestroy(); + await CpsBroadcastChannelStub.settle(); + + const fragment = createFragment(); + fragment.get(CpsTelemetrySink).record('com.cps.bi', { eventName: 'x' }); + await CpsBroadcastChannelStub.settle(); + + expect(second.sink.events).toHaveLength(1); + expect(second.host.received).toBe(1); + }); + }); + + describe('cpsElectBroadcastHostLeader', () => { + afterEach(() => LockManagerStub.uninstall()); + + it('should elect immediately when the Locks API is unavailable', () => { + const onElected = jest.fn(); + cpsElectBroadcastHostLeader('cps-telemetry', onElected); + + expect(onElected).toHaveBeenCalledTimes(1); + }); + + it('should elect once the lock is granted', () => { + LockManagerStub.install(); + const onElected = jest.fn(); + cpsElectBroadcastHostLeader('cps-telemetry', onElected); + + expect(onElected).toHaveBeenCalledTimes(1); + }); + + it('should fail open and still elect when request() itself rejects', async () => { + Object.defineProperty(globalThis.navigator, 'locks', { + value: { request: () => Promise.reject(new Error('not-fully-active')) }, + configurable: true + }); + const onElected = jest.fn(); + + cpsElectBroadcastHostLeader('cps-telemetry', onElected); + // Let the rejection's microtask settle. + await Promise.resolve(); + await Promise.resolve(); + + expect(onElected).toHaveBeenCalledTimes(1); + }); + + it('should fail open and still elect when request() itself throws synchronously', () => { + Object.defineProperty(globalThis.navigator, 'locks', { + value: { + request: () => { + throw new Error('locks unavailable in this context'); + } + }, + configurable: true + }); + const onElected = jest.fn(); + + expect(() => + cpsElectBroadcastHostLeader('cps-telemetry', onElected) + ).not.toThrow(); + expect(onElected).toHaveBeenCalledTimes(1); + }); + + it('should not elect a requester released while still queued, and should let the next requester through', async () => { + LockManagerStub.install(); + const onElectedHolder = jest.fn(); + const onElectedA = jest.fn(); + const onElectedB = jest.fn(); + + const releaseHolder = cpsElectBroadcastHostLeader( + 'cps-telemetry', + onElectedHolder + ); + const releaseA = cpsElectBroadcastHostLeader('cps-telemetry', onElectedA); + cpsElectBroadcastHostLeader('cps-telemetry', onElectedB); + + // A is released before ever being granted the lock. + releaseA(); + // The current holder releases, so the lock passes down the queue. + releaseHolder(); + for (let i = 0; i < 6; i++) { + await Promise.resolve(); + } + + expect(onElectedA).not.toHaveBeenCalled(); + expect(onElectedB).toHaveBeenCalledTimes(1); + }); + }); + + describe('cpsIsBroadcastMessage', () => { + it.each([null, undefined, 'string', 42, ['array']])( + 'should reject the non-object payload %p', + (data) => { + expect(cpsIsBroadcastMessage(data)).toBe(false); + } + ); + + it('should reject a payload with an unknown kind', () => { + expect(cpsIsBroadcastMessage({ kind: 'other library' })).toBe(false); + }); + + it('should accept a well-formed event message', () => { + expect( + cpsIsBroadcastMessage({ + kind: 'event', + eventType: 'com.cps.bi', + payload: { eventName: 'x' } + }) + ).toBe(true); + }); + + it.each([ + { eventType: 'com.cps.bi' }, + { payload: {} }, + { eventType: 1, payload: {} }, + { eventType: 'com.cps.bi', payload: 'not-an-object' }, + { + eventType: 'com.cps.bi', + payload: {}, + metadata: { nested: {} } + }, + { + eventType: 'com.cps.bi', + payload: {}, + metadata: { list: [1, 2] } + }, + { + eventType: 'com.cps.bi', + payload: {}, + metadata: 'not-an-object' + } + ])('should reject a malformed event message %p', (fields) => { + expect(cpsIsBroadcastMessage({ kind: 'event', ...fields })).toBe(false); + }); + + it('should accept an event message with flat-primitive metadata', () => { + expect( + cpsIsBroadcastMessage({ + kind: 'event', + eventType: 'com.cps.bi', + payload: {}, + metadata: { count: 3, ok: true, note: 'x', blank: null } + }) + ).toBe(true); + }); + + it('should accept an event message with no metadata', () => { + expect( + cpsIsBroadcastMessage({ + kind: 'event', + eventType: 'com.cps.bi', + payload: {} + }) + ).toBe(true); + }); + + it('should accept a well-formed error message', () => { + expect( + cpsIsBroadcastMessage({ + kind: 'error', + error: { name: 'Error', message: 'boom' } + }) + ).toBe(true); + }); + + it.each([ + {}, + { error: { name: 'Error' } }, + { error: { message: 'boom' } }, + { error: 'boom' }, + { error: { name: 'Error', message: 'boom', stack: 42 } }, + { + error: { name: 'Error', message: 'boom' }, + metadata: { nested: {} } + } + ])('should reject a malformed error message %p', (fields) => { + expect(cpsIsBroadcastMessage({ kind: 'error', ...fields })).toBe(false); + }); + + it('should accept an error message with flat-primitive metadata', () => { + expect( + cpsIsBroadcastMessage({ + kind: 'error', + error: { name: 'Error', message: 'boom' }, + metadata: { origin: 'checkout' } + }) + ).toBe(true); + }); + + it('should accept an error message with a string stack', () => { + expect( + cpsIsBroadcastMessage({ + kind: 'error', + error: { name: 'Error', message: 'boom', stack: 'at foo.ts:1' } + }) + ).toBe(true); + }); + + it('should accept a well-formed flush message', () => { + expect(cpsIsBroadcastMessage({ kind: 'flush', beacon: true })).toBe(true); + }); + + it('should reject a flush message with a non-boolean beacon', () => { + expect(cpsIsBroadcastMessage({ kind: 'flush', beacon: 'true' })).toBe( + false + ); + }); + + it.each([{ userId: 'u-1' }, { userId: undefined }, {}])( + 'should accept a well-formed user message %p', + (fields) => { + expect(cpsIsBroadcastMessage({ kind: 'user', ...fields })).toBe(true); + } + ); + + it('should reject a user message with a non-string, non-undefined userId', () => { + expect(cpsIsBroadcastMessage({ kind: 'user', userId: 42 })).toBe(false); + }); + + const logRecord = { + timestamp: '2026-01-01T00:00:00.000Z', + level: 'warn', + message: 'Careful', + application: 'cart', + environment: 'test', + version: '1.0.0' + }; + + it('should accept a well-formed log message', () => { + expect(cpsIsBroadcastMessage({ kind: 'log', record: logRecord })).toBe( + true + ); + expect( + cpsIsBroadcastMessage({ + kind: 'log', + record: { + ...logRecord, + logger: 'checkout', + context: 'Cart', + correlationId: 'c-1', + sessionId: 's', + userId: 'u', + metadata: { count: 1 }, + error: { name: 'Error', message: 'boom' } + } + }) + ).toBe(true); + }); + + it.each([ + { level: 'debug' }, + { level: 'toString' }, + { application: undefined }, + { message: 42 }, + { logger: 7 }, + { metadata: { nested: {} } }, + { error: { name: 'Error' } } + ])('should reject a log message whose record has %p', (fields) => { + expect( + cpsIsBroadcastMessage({ + kind: 'log', + record: { ...logRecord, ...fields } + }) + ).toBe(false); + }); + + it('should accept log-flush, log-query and log-query-result messages', () => { + expect(cpsIsBroadcastMessage({ kind: 'log-flush' })).toBe(true); + expect( + cpsIsBroadcastMessage({ + kind: 'log-query', + id: 'q-1', + filter: { correlationId: 'c-1', minLevel: 'warn', limit: 5 } + }) + ).toBe(true); + expect( + cpsIsBroadcastMessage({ + kind: 'log-query-result', + id: 'q-1', + records: [] + }) + ).toBe(true); + }); + + it.each([ + { id: '', filter: {} }, + { id: 'q-1', filter: null }, + { id: 'q-1', filter: { limit: 'ten' } }, + { id: 'q-1', filter: { minLevel: 'verbose' } } + ])('should reject a malformed log-query message %p', (fields) => { + expect(cpsIsBroadcastMessage({ kind: 'log-query', ...fields })).toBe( + false + ); + }); + + it('should reject a log-query-result without a records array', () => { + expect( + cpsIsBroadcastMessage({ + kind: 'log-query-result', + id: 'q-1', + records: 'none' + }) + ).toBe(false); + }); + + it('should accept an identity-request message with no other fields', () => { + expect(cpsIsBroadcastMessage({ kind: 'identity-request' })).toBe(true); + }); + + it.each([ + {}, + { sessionId: 's-1' }, + { userId: 'u-1' }, + { + sessionId: 's-1', + userId: 'u-1' + } + ])('should accept an identity message %p', (fields) => { + expect(cpsIsBroadcastMessage({ kind: 'identity', ...fields })).toBe(true); + }); + + it.each([{ sessionId: 42 }, { userId: 42 }, { sessionId: 42, userId: 42 }])( + 'should reject an identity message with a non-string, non-undefined field %p', + (fields) => { + expect(cpsIsBroadcastMessage({ kind: 'identity', ...fields })).toBe( + false + ); + } + ); + }); + + describe('cpsConnectBroadcastChannel', () => { + it('should default the channel name to CPS_DEFAULT_BROADCAST_CHANNEL', () => { + const connection = TestBed.runInInjectionContext(() => + cpsConnectBroadcastChannel('test') + ); + expect(connection.channelName).toBe(CPS_DEFAULT_BROADCAST_CHANNEL); + }); + + it('should read an overridden channel name from CPS_BROADCAST_CHANNEL', () => { + TestBed.configureTestingModule({ + providers: [{ provide: CPS_BROADCAST_CHANNEL, useValue: 'my-channel' }] + }); + const connection = TestBed.runInInjectionContext(() => + cpsConnectBroadcastChannel('test') + ); + expect(connection.channelName).toBe('my-channel'); + }); + + it('should invoke the registered handler for an incoming message', async () => { + const connection = TestBed.runInInjectionContext(() => + cpsConnectBroadcastChannel('test') + ); + const received: unknown[] = []; + connection.onMessage((data) => received.push(data)); + + const peer = new CpsBroadcastChannelStub(CPS_DEFAULT_BROADCAST_CHANNEL); + peer.postMessage({ kind: 'flush', beacon: true }); + await CpsBroadcastChannelStub.settle(); + + expect(received).toEqual([{ kind: 'flush', beacon: true }]); + }); + + it('should stop delivering to a closed connection', async () => { + const connection = TestBed.runInInjectionContext(() => + cpsConnectBroadcastChannel('test') + ); + const received: unknown[] = []; + connection.onMessage((data) => received.push(data)); + expect(connection.isOpen()).toBe(true); + connection.close(); + expect(connection.isOpen()).toBe(false); + + const peer = new CpsBroadcastChannelStub(CPS_DEFAULT_BROADCAST_CHANNEL); + peer.postMessage({ kind: 'flush', beacon: true }); + await CpsBroadcastChannelStub.settle(); + + expect(received).toEqual([]); + }); + + it('should degrade to a safe no-op when BroadcastChannel is unavailable', () => { + CpsBroadcastChannelStub.uninstall(); + const connection = TestBed.runInInjectionContext(() => + cpsConnectBroadcastChannel('test') + ); + + expect(() => { + connection.post({ kind: 'identity-request' }); + connection.onMessage(() => undefined); + connection.close(); + }).not.toThrow(); + expect(connection.isOpen()).toBe(false); + }); + }); + + describe('without BroadcastChannel support', () => { + beforeEach(() => CpsBroadcastChannelStub.uninstall()); + + it('should degrade to a no-op sink rather than throwing', () => { + const fragment = createFragment(); + const sink = fragment.get(CpsTelemetrySink); + + expect(() => { + sink.record('com.cps.bi', { eventName: 'x' }); + sink.recordError({ name: 'Error', message: 'boom' }); + sink.setUserId('user-1'); + sink.flush(true); + }).not.toThrow(); + expect(sink.getSessionId()).toBeUndefined(); + }); + + it('should resolve a fragment log query to [] at once', async () => { + const fragment = createFragment(); + await expect(fragment.get(CpsLoggerService).query({})).resolves.toEqual( + [] + ); + }); + + it('should let a scenario run to completion in the fragment', () => { + const fragment = createFragment(); + const scenarioTelemetry = fragment.get(CpsScenarioTelemetryService); + + const scenario = scenarioTelemetry.start({ name: 'add-to-cart' }); + expect(() => scenario.step('one').complete()).not.toThrow(); + expect(scenario.status).toBe('success'); + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/sinks/cps-telemetry/cps-noop-telemetry.sink/cps-noop-telemetry.sink.spec.ts b/projects/cps-telemetry/src/lib/sinks/cps-telemetry/cps-noop-telemetry.sink/cps-noop-telemetry.sink.spec.ts new file mode 100644 index 000000000..d63dc5aaa --- /dev/null +++ b/projects/cps-telemetry/src/lib/sinks/cps-telemetry/cps-noop-telemetry.sink/cps-noop-telemetry.sink.spec.ts @@ -0,0 +1,53 @@ +import { CpsNoopTelemetrySink } from './cps-noop-telemetry.sink'; +import { CpsTelemetrySink } from '../cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; + +describe('CpsNoopTelemetrySink', () => { + let sink: CpsTelemetrySink; + + beforeEach(() => { + sink = new CpsNoopTelemetrySink(); + }); + + it('should discard record() without throwing', () => { + expect(() => sink.record('com.cps.bi', { x: 1 })).not.toThrow(); + }); + + it('should discard recordError() without throwing', () => { + expect(() => + sink.recordError({ name: 'Error', message: 'boom' }) + ).not.toThrow(); + }); + + it('should report no session id', () => { + expect(sink.getSessionId()).toBeUndefined(); + }); + + it('should discard setUserId() without throwing', () => { + expect(() => sink.setUserId('user-1')).not.toThrow(); + }); + + it('should report the user id last given to setUserId(), even though telemetry itself is discarded', () => { + sink.setUserId('user-1'); + expect(sink.getUserId()).toBe('user-1'); + }); + + it('should report no user id again after signing out', () => { + sink.setUserId('user-1'); + sink.setUserId(undefined); + expect(sink.getUserId()).toBeUndefined(); + }); + + it('should discard flush() without throwing', () => { + expect(() => sink.flush(true)).not.toThrow(); + }); + + it('should accept the full sink API when typed as the concrete class itself, not just through CpsTelemetrySink', () => { + const concrete = new CpsNoopTelemetrySink(); + expect(() => { + concrete.record('com.cps.bi', { x: 1 }, { a: 1 }); + concrete.recordError({ name: 'Error', message: 'boom' }, { a: 1 }); + concrete.setUserId('user-1'); + concrete.flush(true); + }).not.toThrow(); + }); +}); diff --git a/projects/cps-telemetry/src/lib/sinks/cps-telemetry/cps-noop-telemetry.sink/cps-noop-telemetry.sink.ts b/projects/cps-telemetry/src/lib/sinks/cps-telemetry/cps-noop-telemetry.sink/cps-noop-telemetry.sink.ts new file mode 100644 index 000000000..45c4c5ff4 --- /dev/null +++ b/projects/cps-telemetry/src/lib/sinks/cps-telemetry/cps-noop-telemetry.sink/cps-noop-telemetry.sink.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@angular/core'; +import { + CpsTelemetryError, + CpsTelemetryMetadata +} from '../../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { CpsTelemetrySink } from '../cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; + +/** + * Sink that discards everything. + * + * Selected with `provideCpsTelemetrySink('noop')` — never a default. Debug + * flags, logging and scenario mechanics all still work, with no AWS account + * or `aws-rum-web` dependency needed. + * + * @group Services + */ +@Injectable() +export class CpsNoopTelemetrySink extends CpsTelemetrySink { + private userId?: string; + + /** @inheritdoc */ + record( + _eventType: string, + _payload: object, + _metadata?: CpsTelemetryMetadata + ): void {} + + /** @inheritdoc */ + recordError( + _error: CpsTelemetryError, + _metadata?: CpsTelemetryMetadata + ): void {} + + /** @inheritdoc */ + getSessionId(): string | undefined { + return undefined; + } + + /** @inheritdoc */ + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + /** @inheritdoc */ + getUserId(): string | undefined { + return this.userId; + } + + /** @inheritdoc */ + flush(_beacon?: boolean): void {} +} diff --git a/projects/cps-telemetry/src/lib/sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink.ts b/projects/cps-telemetry/src/lib/sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink.ts new file mode 100644 index 000000000..c0d9fcd2e --- /dev/null +++ b/projects/cps-telemetry/src/lib/sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink.ts @@ -0,0 +1,69 @@ +import { Injectable } from '@angular/core'; +import { + CpsTelemetryError, + CpsTelemetryMetadata +} from '../../../models/cps-telemetry-common.models/cps-telemetry-common.models'; + +/** + * Destination for scenario and BI telemetry. + * + * Application code never touches this directly — it lets the AWS RUM + * integration be replaced or stubbed with no change to the telemetry + * services or the applications that use them. + * + * @group Services + */ +@Injectable() +export abstract class CpsTelemetrySink { + /** + * Records one custom event. Must be non-throwing and non-blocking. + * + * @param eventType one of {@link CPS_TELEMETRY_EVENT_TYPE} + * @param payload the event body + * @param metadata optional flat attributes attached to the event envelope + */ + abstract record( + eventType: string, + payload: object, + metadata?: CpsTelemetryMetadata + ): void; + + /** + * Records a handled error as a first-class error signal, alongside errors + * the RUM client captures itself. Only reached when + * {@link CpsLogConfig.mirrorErrorsToRum} is enabled. + * + * @param error the normalized error + * @param metadata optional flat attributes attached to the error envelope + * (e.g. a forwarding sink's own origin identity) + */ + abstract recordError( + error: CpsTelemetryError, + metadata?: CpsTelemetryMetadata + ): void; + + /** Returns the current session identifier, when the implementation has one. */ + abstract getSessionId(): string | undefined; + + /** + * Associates subsequent telemetry with an application user identifier, or + * with nobody. `undefined` means signed out, and so does `''`: an empty + * string identifies nobody, and treating it as sign-out means an + * application clearing the id with `''` really stops attributing + * telemetry to the previous user. + * + * @param userId the application's own user identifier, or `undefined` + * (or `''`) to stop attributing telemetry to the previous one + */ + abstract setUserId(userId: string | undefined): void; + + /** Returns the user identifier last given to {@link setUserId}, if any. */ + abstract getUserId(): string | undefined; + + /** + * Requests that buffered telemetry be sent immediately. + * + * @param beacon use a transport that survives page unload + */ + abstract flush(beacon?: boolean): void; +} diff --git a/projects/cps-telemetry/src/lib/utils/cps-debug-flag.util/cps-debug-flag.util.spec.ts b/projects/cps-telemetry/src/lib/utils/cps-debug-flag.util/cps-debug-flag.util.spec.ts new file mode 100644 index 000000000..74502daff --- /dev/null +++ b/projects/cps-telemetry/src/lib/utils/cps-debug-flag.util/cps-debug-flag.util.spec.ts @@ -0,0 +1,94 @@ +import { cpsIsDebugEnabled } from './cps-debug-flag.util'; + +describe('cpsIsDebugEnabled', () => { + afterEach(() => { + localStorage.clear(); + jest.restoreAllMocks(); + }); + + it('should be disabled by default when the key is absent', () => { + expect(cpsIsDebugEnabled('debugLogger')).toBe(false); + expect(cpsIsDebugEnabled('debugScenario')).toBe(false); + expect(cpsIsDebugEnabled('debugBI')).toBe(false); + }); + + it('should be enabled by the string "true"', () => { + localStorage.setItem('debugLogger', 'true'); + expect(cpsIsDebugEnabled('debugLogger')).toBe(true); + }); + + it('should be enabled by the string "1"', () => { + localStorage.setItem('debugScenario', '1'); + expect(cpsIsDebugEnabled('debugScenario')).toBe(true); + }); + + it('should accept surrounding whitespace and mixed casing', () => { + localStorage.setItem('debugBI', ' TRUE '); + expect(cpsIsDebugEnabled('debugBI')).toBe(true); + }); + + it.each(['false', '0', 'yes', 'on', '', 'null', 'undefined', '2'])( + 'should stay disabled for the invalid value "%s"', + (value) => { + localStorage.setItem('debugLogger', value); + expect(cpsIsDebugEnabled('debugLogger')).toBe(false); + } + ); + + it('should stay disabled when localStorage throws', () => { + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('SecurityError: access denied'); + }); + expect(cpsIsDebugEnabled('debugLogger')).toBe(false); + }); + + it('should read the flag on every call so DevTools toggles take effect', () => { + expect(cpsIsDebugEnabled('debugLogger')).toBe(false); + localStorage.setItem('debugLogger', 'true'); + expect(cpsIsDebugEnabled('debugLogger')).toBe(true); + localStorage.removeItem('debugLogger'); + expect(cpsIsDebugEnabled('debugLogger')).toBe(false); + }); + + describe('scoped by name', () => { + it('should enable only a matching name', () => { + localStorage.setItem('debugLogger', 'checkout'); + + expect(cpsIsDebugEnabled('debugLogger', 'checkout')).toBe(true); + expect(cpsIsDebugEnabled('debugLogger', 'admin')).toBe(false); + }); + + it('should accept a comma-separated list', () => { + localStorage.setItem('debugLogger', 'checkout,admin'); + + expect(cpsIsDebugEnabled('debugLogger', 'checkout')).toBe(true); + expect(cpsIsDebugEnabled('debugLogger', 'admin')).toBe(true); + expect(cpsIsDebugEnabled('debugLogger', 'reports')).toBe(false); + }); + + it('should ignore whitespace and case around each entry', () => { + localStorage.setItem('debugLogger', ' Checkout , ADMIN '); + + expect(cpsIsDebugEnabled('debugLogger', 'checkout')).toBe(true); + expect(cpsIsDebugEnabled('debugLogger', 'admin')).toBe(true); + }); + + it('should read a list as disabled for a caller that asks unqualified', () => { + localStorage.setItem('debugLogger', 'checkout'); + + expect(cpsIsDebugEnabled('debugLogger')).toBe(false); + }); + + it('should enable every name when the value is "true"', () => { + localStorage.setItem('debugLogger', 'true'); + + expect(cpsIsDebugEnabled('debugLogger', 'checkout')).toBe(true); + expect(cpsIsDebugEnabled('debugLogger', 'anything')).toBe(true); + expect(cpsIsDebugEnabled('debugLogger')).toBe(true); + }); + + it('should stay disabled for a name when the flag is unset', () => { + expect(cpsIsDebugEnabled('debugLogger', 'checkout')).toBe(false); + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/utils/cps-debug-flag.util/cps-debug-flag.util.ts b/projects/cps-telemetry/src/lib/utils/cps-debug-flag.util/cps-debug-flag.util.ts new file mode 100644 index 000000000..8296cbb76 --- /dev/null +++ b/projects/cps-telemetry/src/lib/utils/cps-debug-flag.util/cps-debug-flag.util.ts @@ -0,0 +1,78 @@ +import { cpsSafeVoid } from '../cps-telemetry-safe.util/cps-telemetry-safe.util'; + +/** + * LocalStorage keys recognised as telemetry debug switches. + * + * @group Types + */ +export type CpsDebugFlag = 'debugLogger' | 'debugScenario' | 'debugBI'; + +const ENABLED_VALUES = new Set(['true', '1']); + +/** + * Reports whether a telemetry debug flag is enabled in LocalStorage. + * + * Only `'true'` and `'1'` enable it (case-insensitive, trimmed). Any other + * non-empty value is treated as a comma-separated list of names, enabling the + * flag only when `name` matches one of them; a call with no `name` reads a + * list value as disabled. A missing key or a throwing `localStorage` also + * reads as disabled. + * + * @param flag the LocalStorage key to check + * @param name the name to test against a list value, when the flag is scoped + * @returns `true` only when the flag is enabled for this name + * + * @group Utils + */ +export function cpsIsDebugEnabled(flag: CpsDebugFlag, name?: string): boolean { + try { + const raw = globalThis.localStorage?.getItem(flag); + if (typeof raw !== 'string') { + return false; + } + + const value = raw.trim().toLowerCase(); + if (ENABLED_VALUES.has(value)) { + return true; + } + + if (name === undefined || !value) { + return false; + } + + return value + .split(',') + .some((entry) => entry.trim() === name.trim().toLowerCase()); + } catch { + return false; + } +} + +/** + * Writes a debug line to the console, but only when `flag` is on, and + * never at the caller's expense. + * + * The write is isolated in its own {@link cpsSafeVoid} rather than left to + * whatever wrapper the caller already sits inside. That isolation is the + * point: every caller prints a debug line immediately *before* handing the + * same payload to a sink or transport, so a patched or broken `console` + * must not be able to abort the send. Writing the line and shipping the + * data are independent failures. + * + * @param flag the debug switch that has to be on + * @param write performs the console call; never runs when the flag is off + * @param name the name to test against a list-valued flag, e.g. a logger + * name for `debugLogger` + * + * @group Utils + */ +export function cpsDebugWrite( + flag: CpsDebugFlag, + write: () => void, + name?: string +): void { + if (!cpsIsDebugEnabled(flag, name)) { + return; + } + cpsSafeVoid(`${flag}.write`, write); +} diff --git a/projects/cps-telemetry/src/lib/utils/cps-telemetry-redact.util/cps-telemetry-redact.util.spec.ts b/projects/cps-telemetry/src/lib/utils/cps-telemetry-redact.util/cps-telemetry-redact.util.spec.ts new file mode 100644 index 000000000..e4a1dc43d --- /dev/null +++ b/projects/cps-telemetry/src/lib/utils/cps-telemetry-redact.util/cps-telemetry-redact.util.spec.ts @@ -0,0 +1,782 @@ +import { + CPS_DEFAULT_REDACT_CONFIG, + CPS_REDACTED, + cpsMergeMetadata, + cpsNormalizeError, + cpsRedactConfigFor, + cpsRedactMetadata, + cpsScrubString +} from './cps-telemetry-redact.util'; + +describe('cpsScrubString', () => { + it('should strip the query string and fragment from an absolute URL', () => { + expect( + cpsScrubString( + 'https://api.example.com/customers?token=abc123#section', + CPS_DEFAULT_REDACT_CONFIG + ) + ).toBe('https://api.example.com/customers'); + }); + + it('should strip a URL embedded inside a longer message', () => { + expect( + cpsScrubString( + 'Request to https://api.example.com/v1/users?apiKey=secret failed', + CPS_DEFAULT_REDACT_CONFIG + ) + ).toBe('Request to https://api.example.com/v1/users failed'); + }); + + it('should strip the query string from a root-relative path', () => { + expect( + cpsScrubString('/customers?ssn=123-45-6789', CPS_DEFAULT_REDACT_CONFIG) + ).toBe('/customers'); + }); + + it('should strip a root-relative path embedded inside a longer message', () => { + expect( + cpsScrubString( + 'Request to /api/customers/search?email=john@example.com&ssn=123-45-6789 failed', + CPS_DEFAULT_REDACT_CONFIG + ) + ).toBe('Request to /api/customers/search failed'); + }); + + it('should strip more than one embedded root-relative path in the same string', () => { + expect( + cpsScrubString( + 'compare /a/x?p=1 against /b/y?q=2 now', + CPS_DEFAULT_REDACT_CONFIG + ) + ).toBe('compare /a/x against /b/y now'); + }); + + it('should strip a root-relative path with no space before it', () => { + expect( + cpsScrubString( + 'Redirected to:/dashboard?sessionToken=abc123', + CPS_DEFAULT_REDACT_CONFIG + ) + ).toBe('Redirected to:/dashboard'); + }); + + it('should leave a plain message that merely ends in a question mark intact', () => { + expect( + cpsScrubString('Could not load the data?', CPS_DEFAULT_REDACT_CONFIG) + ).toBe('Could not load the data?'); + }); + + it('should truncate strings beyond the configured cap', () => { + const result = cpsScrubString('x'.repeat(50), { + ...CPS_DEFAULT_REDACT_CONFIG, + maxStringLength: 10 + }); + expect(result).toBe(`${'x'.repeat(10)}…`); + }); + + it('should leave URLs alone when stripping is disabled', () => { + expect( + cpsScrubString('https://example.com/a?b=c', { + ...CPS_DEFAULT_REDACT_CONFIG, + stripUrlQuery: false + }) + ).toBe('https://example.com/a?b=c'); + }); + + describe('value-pattern scanning', () => { + it('should not scan for any value pattern by default', () => { + expect( + cpsScrubString( + 'contact john.smith@example.com, card 4111111111111111, ssn 123-45-6789', + CPS_DEFAULT_REDACT_CONFIG + ) + ).toBe( + 'contact john.smith@example.com, card 4111111111111111, ssn 123-45-6789' + ); + }); + + it('should redact an email address when enabled', () => { + expect( + cpsScrubString('contact john.smith@example.com for help', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['email'] + }) + ).toBe(`contact ${CPS_REDACTED} for help`); + }); + + it('should not match a bare @-handle with no domain as an email', () => { + expect( + cpsScrubString('cc @someuser on this', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['email'] + }) + ).toBe('cc @someuser on this'); + }); + + it('should catch an email embedded in a URL path segment', () => { + expect( + cpsScrubString('/customers/john.smith@example.com', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['email'] + }) + ).toBe(`/customers/${CPS_REDACTED}`); + }); + + it('should redact a Luhn-valid credit card number when enabled', () => { + expect( + cpsScrubString('card on file: 4111111111111111', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['creditCard'] + }) + ).toBe(`card on file: ${CPS_REDACTED}`); + }); + + it('should leave a 16-digit run alone when it fails the Luhn checksum', () => { + expect( + cpsScrubString('order number: 1234567890123456', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['creditCard'] + }) + ).toBe('order number: 1234567890123456'); + }); + + it('should redact a well-formatted SSN when enabled', () => { + expect( + cpsScrubString('ssn on file: 123-45-6789', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['ssn'] + }) + ).toBe(`ssn on file: ${CPS_REDACTED}`); + }); + + it('should not match a bare 9-digit run with no dashes as an SSN', () => { + expect( + cpsScrubString('reference 123456789', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['ssn'] + }) + ).toBe('reference 123456789'); + }); + + it('should redact an IPv4 address when enabled', () => { + expect( + cpsScrubString('client at 192.168.1.1', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['ipv4'] + }) + ).toBe(`client at ${CPS_REDACTED}`); + }); + + it('should not match an out-of-range octet as an IPv4 address', () => { + expect( + cpsScrubString('version 999.999.999.999', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['ipv4'] + }) + ).toBe('version 999.999.999.999'); + }); + + it('should redact a US-shaped phone number when enabled', () => { + expect( + cpsScrubString('call (555) 123-4567', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['phone'] + }) + ).toBe(`call ${CPS_REDACTED}`); + }); + + it('should redact a South African phone number in international format when enabled', () => { + expect( + cpsScrubString('call +27 82 123 4567', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['phone'] + }) + ).toBe(`call ${CPS_REDACTED}`); + }); + + it('should redact a value matching an application-supplied pattern', () => { + expect( + cpsScrubString('internal ref ACC-98765', { + ...CPS_DEFAULT_REDACT_CONFIG, + extraValuePatterns: [/ACC-\d+/] + }) + ).toBe(`internal ref ${CPS_REDACTED}`); + }); + + it('should redact every occurrence even when the supplied pattern has no g flag', () => { + expect( + cpsScrubString('ref ACC-111 and also ref ACC-222', { + ...CPS_DEFAULT_REDACT_CONFIG, + extraValuePatterns: [/ACC-\d+/] + }) + ).toBe(`ref ${CPS_REDACTED} and also ref ${CPS_REDACTED}`); + }); + + it('should redact correctly across repeated calls reusing the same non-global pattern object', () => { + const pattern = /ACC-\d+/; + const config = { + ...CPS_DEFAULT_REDACT_CONFIG, + extraValuePatterns: [pattern] + }; + + expect(cpsScrubString('ref ACC-111 and also ACC-222', config)).toBe( + `ref ${CPS_REDACTED} and also ${CPS_REDACTED}` + ); + + expect(cpsScrubString('ref ACC-333 and also ACC-444', config)).toBe( + `ref ${CPS_REDACTED} and also ${CPS_REDACTED}` + ); + }); + + it('should not recompile the same non-global pattern into a new RegExp on every call', () => { + const RegExpSpy = jest.spyOn(globalThis, 'RegExp'); + const pattern = /ACC-\d+/; + const config = { + ...CPS_DEFAULT_REDACT_CONFIG, + extraValuePatterns: [pattern] + }; + + cpsScrubString('ref ACC-111', config); + const callsAfterFirst = RegExpSpy.mock.calls.length; + cpsScrubString('ref ACC-222', config); + const callsAfterSecond = RegExpSpy.mock.calls.length; + + expect(callsAfterFirst).toBeGreaterThan(0); + expect(callsAfterSecond).toBe(callsAfterFirst); + + RegExpSpy.mockRestore(); + }); + + it('should fully redact a credit card number regardless of scanValuePatterns order', () => { + const value = 'card on file: 4111111111111111'; + expect( + cpsScrubString(value, { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['phone', 'creditCard'] + }) + ).toBe(`card on file: ${CPS_REDACTED}`); + expect( + cpsScrubString(value, { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['creditCard', 'phone'] + }) + ).toBe(`card on file: ${CPS_REDACTED}`); + }); + + it('should apply multiple enabled patterns to the same string', () => { + expect( + cpsScrubString('email a@b.com or call (555) 123-4567', { + ...CPS_DEFAULT_REDACT_CONFIG, + scanValuePatterns: ['email', 'phone'] + }) + ).toBe(`email ${CPS_REDACTED} or call ${CPS_REDACTED}`); + }); + }); + + describe('extraValueTransforms', () => { + it('should run a custom transform on every string value', () => { + expect( + cpsScrubString('internal-id ACC-12345', { + ...CPS_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [ + (value) => value.replace(/ACC-\d+/, CPS_REDACTED) + ] + }) + ).toBe(`internal-id ${CPS_REDACTED}`); + }); + + it('should run regardless of scanValuePatterns/extraValuePatterns being empty', () => { + expect( + cpsScrubString('plain text', { + ...CPS_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [() => 'replaced'] + }) + ).toBe('replaced'); + }); + + it('should run multiple transforms in array order', () => { + expect( + cpsScrubString('start', { + ...CPS_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [ + (value) => `${value}-a`, + (value) => `${value}-b` + ] + }) + ).toBe('start-a-b'); + }); + + describe('a throwing transform', () => { + let consoleWarn: jest.SpyInstance; + + beforeEach(() => { + consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleWarn.mockRestore(); + }); + + it('should skip it and keep the value from before it, reporting why', () => { + expect( + cpsScrubString('unchanged', { + ...CPS_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [ + () => { + throw new Error('broken transform'); + } + ] + }) + ).toBe('unchanged'); + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining('extraValueTransforms'), + expect.any(Error) + ); + }); + + it('should still apply subsequent transforms after it', () => { + expect( + cpsScrubString('start', { + ...CPS_DEFAULT_REDACT_CONFIG, + extraValueTransforms: [ + () => { + throw new Error('broken transform'); + }, + (value) => `${value}-ok` + ] + }) + ).toBe('start-ok'); + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining('extraValueTransforms'), + expect.any(Error) + ); + }); + }); + + it('should still cap length after custom transforms run', () => { + expect( + cpsScrubString('short', { + ...CPS_DEFAULT_REDACT_CONFIG, + maxStringLength: 5, + extraValueTransforms: [() => 'a much longer replacement value'] + }) + ).toBe('a muc…'); + }); + }); +}); + +describe('cpsRedactMetadata', () => { + it('should keep primitive values', () => { + expect( + cpsRedactMetadata({ count: 3, name: 'csv', ok: true, empty: null }) + ).toEqual({ count: 3, name: 'csv', ok: true, empty: null }); + }); + + it.each([ + 'password', + 'passwd', + 'accessToken', + 'refresh_token', + 'Authorization', + 'clientSecret', + 'apiKey', + 'api_key', + 'Cookie', + 'bearerToken', + 'jwt', + 'signature', + 'sessionKey', + 'ssn' + ])('should redact the sensitive key "%s"', (key) => { + expect(cpsRedactMetadata({ [key]: 'super-secret-value' })).toEqual({ + [key]: CPS_REDACTED + }); + }); + + it('should redact keys matching an application-supplied pattern', () => { + expect( + cpsRedactMetadata( + { customerRef: 'ABC' }, + { ...CPS_DEFAULT_REDACT_CONFIG, extraKeyPatterns: [/customerRef/i] } + ) + ).toEqual({ customerRef: CPS_REDACTED }); + }); + + it('should redact the same key consistently across repeated calls with a global pattern', () => { + const config = { + ...CPS_DEFAULT_REDACT_CONFIG, + extraKeyPatterns: [/internalId/gi] + }; + for (let i = 0; i < 4; i++) { + expect(cpsRedactMetadata({ internalId: 'value' }, config)).toEqual({ + internalId: CPS_REDACTED + }); + } + }); + + it('should drop nested objects rather than serializing them', () => { + expect( + cpsRedactMetadata({ user: { id: 1, email: 'a@b.c' }, safe: 'yes' }) + ).toEqual({ safe: 'yes' }); + }); + + it('should drop arrays, functions, symbols and undefined', () => { + expect( + cpsRedactMetadata({ + rows: [1, 2, 3], + fn: () => undefined, + sym: Symbol('s'), + missing: undefined, + kept: 1 + }) + ).toEqual({ kept: 1 }); + }); + + it('should drop non-finite numbers', () => { + expect( + cpsRedactMetadata({ a: NaN, b: Infinity, c: -Infinity, d: 0 }) + ).toEqual({ d: 0 }); + }); + + it('should scrub URLs inside string values', () => { + expect(cpsRedactMetadata({ url: 'https://x.dev/p?token=1' })).toEqual({ + url: 'https://x.dev/p' + }); + }); + + it('should cap the number of retained keys', () => { + const consoleWarn = jest + .spyOn(console, 'warn') + .mockImplementation(() => {}); + + const input: Record<string, number> = {}; + for (let i = 0; i < 100; i++) { + input[`k${i}`] = i; + } + const result = cpsRedactMetadata(input, { + ...CPS_DEFAULT_REDACT_CONFIG, + maxKeys: 5 + }); + expect(Object.keys(result ?? {})).toHaveLength(5); + + consoleWarn.mockRestore(); + }); + + describe('truncation warning', () => { + let consoleWarn: jest.SpyInstance; + + beforeEach(() => { + consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleWarn.mockRestore(); + }); + + it('should warn once when maxKeys actually truncates something', () => { + cpsRedactMetadata( + { a: 1, b: 2, c: 3 }, + { ...CPS_DEFAULT_REDACT_CONFIG, maxKeys: 2 } + ); + + expect(consoleWarn).toHaveBeenCalledTimes(1); + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining('maxKeys') + ); + }); + + it('should stay silent when every key fits under maxKeys', () => { + cpsRedactMetadata( + { a: 1, b: 2 }, + { ...CPS_DEFAULT_REDACT_CONFIG, maxKeys: 2 } + ); + + expect(consoleWarn).not.toHaveBeenCalled(); + }); + }); + + it('should not throw on a cyclic object', () => { + const cyclic: Record<string, unknown> = { name: 'root' }; + cyclic.self = cyclic; + expect(cpsRedactMetadata(cyclic)).toEqual({ name: 'root' }); + }); + + it.each([[null], [undefined], ['string'], [42], [[1, 2]]])( + 'should return undefined for the non-object input %p', + (input) => { + expect(cpsRedactMetadata(input)).toBeUndefined(); + } + ); + + it('should return undefined when nothing survives redaction', () => { + expect(cpsRedactMetadata({ nested: { a: 1 } })).toBeUndefined(); + }); +}); + +describe('cpsMergeMetadata', () => { + it('should return target unchanged when incoming is undefined', () => { + const target = { a: 1 }; + expect(cpsMergeMetadata(target, undefined)).toBe(target); + expect(target).toEqual({ a: 1 }); + }); + + it('should merge a disjoint bag in when under maxKeys', () => { + const target = { a: 1 }; + const result = cpsMergeMetadata( + target, + { b: 2 }, + { + ...CPS_DEFAULT_REDACT_CONFIG, + maxKeys: 5 + } + ); + expect(result).toEqual({ a: 1, b: 2 }); + }); + + it('should drop a genuinely new key once the combined count reaches maxKeys', () => { + const target = { a: 1 }; + const result = cpsMergeMetadata( + target, + { b: 2 }, + { + ...CPS_DEFAULT_REDACT_CONFIG, + maxKeys: 1 + } + ); + expect(result).toEqual({ a: 1 }); + }); + + it('should allow updating a key already present even when target is at maxKeys', () => { + const target = { a: 1 }; + const result = cpsMergeMetadata( + target, + { a: 2 }, + { + ...CPS_DEFAULT_REDACT_CONFIG, + maxKeys: 1 + } + ); + expect(result).toEqual({ a: 2 }); + }); + + it('should mutate and return the same target reference', () => { + const target = { a: 1 }; + expect(cpsMergeMetadata(target, { b: 2 })).toBe(target); + }); + + describe('truncation warning', () => { + let consoleWarn: jest.SpyInstance; + + beforeEach(() => { + consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleWarn.mockRestore(); + }); + + it('should warn once when maxKeys actually truncates the combined result', () => { + cpsMergeMetadata( + { a: 1 }, + { b: 2 }, + { + ...CPS_DEFAULT_REDACT_CONFIG, + maxKeys: 1 + } + ); + + expect(consoleWarn).toHaveBeenCalledTimes(1); + expect(consoleWarn).toHaveBeenCalledWith( + expect.stringContaining('maxKeys') + ); + }); + + it('should stay silent when the combined bag fits under maxKeys', () => { + cpsMergeMetadata( + { a: 1 }, + { b: 2 }, + { + ...CPS_DEFAULT_REDACT_CONFIG, + maxKeys: 2 + } + ); + + expect(consoleWarn).not.toHaveBeenCalled(); + }); + + it('should stay silent when only existing keys are updated at maxKeys', () => { + cpsMergeMetadata( + { a: 1 }, + { a: 2 }, + { + ...CPS_DEFAULT_REDACT_CONFIG, + maxKeys: 1 + } + ); + + expect(consoleWarn).not.toHaveBeenCalled(); + }); + }); +}); + +describe('cpsNormalizeError', () => { + it('should reduce an Error to name, message and stack', () => { + const result = cpsNormalizeError(new TypeError('boom')); + expect(result?.name).toBe('TypeError'); + expect(result?.message).toBe('boom'); + expect(typeof result?.stack).toBe('string'); + }); + + it.each([ + ['empty', ''], + ['missing', undefined] + ])('should fall back to the generic label when name is %s', (_case, name) => { + const error = new Error('boom'); + (error as { name: unknown }).name = name; + expect(cpsNormalizeError(error)?.name).toBe('Error'); + }); + + it('should omit the stack when capture is disabled', () => { + const result = cpsNormalizeError(new Error('boom'), { + ...CPS_DEFAULT_REDACT_CONFIG, + includeStack: false + }); + expect(result?.stack).toBeUndefined(); + }); + + it('should cap the stack length', () => { + const error = new Error('boom'); + error.stack = 'y'.repeat(5000); + const result = cpsNormalizeError(error, { + ...CPS_DEFAULT_REDACT_CONFIG, + maxStackLength: 100 + }); + expect(result?.stack?.length).toBe(101); + }); + + it('should scrub URLs out of the error message', () => { + const result = cpsNormalizeError( + new Error('GET https://api.dev/me?access_token=xyz returned 401') + ); + expect(result?.message).toBe('GET https://api.dev/me returned 401'); + }); + + it('should accept a thrown string', () => { + expect(cpsNormalizeError('plain failure')).toEqual({ + name: 'Error', + message: 'plain failure' + }); + }); + + it('should extract name and message from an HttpErrorResponse-shaped object', () => { + const httpError = { + name: 'HttpErrorResponse', + message: 'Http failure response for /api/customers: 404 Not Found', + status: 404, + statusText: 'Not Found', + url: '/api/customers', + ok: false, + error: { secret: 'raw response body — must never appear in output' } + }; + + const result = cpsNormalizeError(httpError); + + expect(result).toEqual({ + name: 'HttpErrorResponse', + message: 'Http failure response for /api/customers: 404 Not Found' + }); + expect(JSON.stringify(result)).not.toContain('raw response body'); + }); + + it('should fall back to a generic name when the HTTP-error-shaped object has none', () => { + const result = cpsNormalizeError({ + message: 'Http failure response for /api/x: 500 Internal Server Error', + status: 500, + statusText: 'Internal Server Error' + }); + + expect(result?.name).toBe('HttpErrorResponse'); + }); + + it('should not treat an arbitrary object with a status-shaped key as an HTTP error', () => { + expect( + cpsNormalizeError({ status: 404, message: 'unrelated object' }) + ).toEqual({ name: 'UnknownError', message: CPS_REDACTED }); + }); + + it('should report the type of a thrown object without serializing it', () => { + expect(cpsNormalizeError({ password: 'hunter2' })).toEqual({ + name: 'UnknownError', + message: CPS_REDACTED + }); + }); + + it.each([[null], [undefined]])('should return undefined for %p', (input) => { + expect(cpsNormalizeError(input)).toBeUndefined(); + }); +}); + +describe('cpsRedactConfigFor', () => { + it('should return the same config unchanged when enabled', () => { + expect(cpsRedactConfigFor(CPS_DEFAULT_REDACT_CONFIG, true)).toBe( + CPS_DEFAULT_REDACT_CONFIG + ); + }); + + it('should turn off PII scrubbing when disabled', () => { + const config = cpsRedactConfigFor( + { + ...CPS_DEFAULT_REDACT_CONFIG, + extraKeyPatterns: [/x-internal/], + scanValuePatterns: ['email'], + extraValuePatterns: [/secret-\d+/] + }, + false + ); + + expect(config.extraKeyPatterns).toEqual([]); + expect(config.stripUrlQuery).toBe(false); + expect(config.scanValuePatterns).toEqual([]); + expect(config.extraValuePatterns).toEqual([]); + }); + + it('should keep size caps, error normalization inputs, and extraValueTransforms when disabled', () => { + const transform = (value: string) => value; + const config = cpsRedactConfigFor( + { + ...CPS_DEFAULT_REDACT_CONFIG, + maxStringLength: 10, + maxKeys: 5, + maxStackLength: 100, + includeStack: true, + extraValueTransforms: [transform] + }, + false + ); + + expect(config.maxStringLength).toBe(10); + expect(config.maxKeys).toBe(5); + expect(config.maxStackLength).toBe(100); + expect(config.includeStack).toBe(true); + expect(config.extraValueTransforms).toEqual([transform]); + }); + + it('should still redact a built-in denylisted key when disabled — it is a safety floor, not a privacy opt-in', () => { + const disabled = cpsRedactConfigFor(CPS_DEFAULT_REDACT_CONFIG, false); + + expect(cpsRedactMetadata({ password: 'hunter2' }, disabled)).toEqual({ + password: CPS_REDACTED + }); + }); + + it('should not redact an extraKeyPatterns-only key when disabled', () => { + const disabled = cpsRedactConfigFor( + { ...CPS_DEFAULT_REDACT_CONFIG, extraKeyPatterns: [/x-internal/] }, + false + ); + + expect(cpsRedactMetadata({ 'x-internal-id': 'abc' }, disabled)).toEqual({ + 'x-internal-id': 'abc' + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/utils/cps-telemetry-redact.util/cps-telemetry-redact.util.ts b/projects/cps-telemetry/src/lib/utils/cps-telemetry-redact.util/cps-telemetry-redact.util.ts new file mode 100644 index 000000000..f47356f79 --- /dev/null +++ b/projects/cps-telemetry/src/lib/utils/cps-telemetry-redact.util/cps-telemetry-redact.util.ts @@ -0,0 +1,498 @@ +import { + CpsTelemetryError, + CpsTelemetryMetadata +} from '../../models/cps-telemetry-common.models/cps-telemetry-common.models'; +import { cpsIsDevMode } from '../cps-telemetry-safe.util/cps-telemetry-safe.util'; + +/** + * Named, built-in value-content PII shapes {@link CpsRedactConfig.scanValuePatterns} + * can enable. + * + * @group Types + */ +export type CpsPiiValuePattern = + 'email' | 'creditCard' | 'ssn' | 'ipv4' | 'phone'; + +/** + * Tunables governing what telemetry is allowed to carry off the device. + * + * @group Interfaces + */ +export interface CpsRedactConfig { + /** + * Additional key patterns to redact, merged with the built-in denylist. + * Matched against the attribute key, case-insensitively. + */ + extraKeyPatterns: RegExp[]; + + /** Maximum length of any single string value before truncation. */ + maxStringLength: number; + + /** Maximum number of attributes retained on one payload. */ + maxKeys: number; + + /** Maximum length of a captured stack trace. */ + maxStackLength: number; + + /** Whether stack traces are captured at all. */ + includeStack: boolean; + + /** Whether URL query strings and fragments are stripped from string values. */ + stripUrlQuery: boolean; + + /** + * Value-content PII shapes to scan for, in addition to the key denylist. + * Off by default. Heuristic regexes, not certified detectors — `'phone'` + * is the noisiest; `'creditCard'` also requires a Luhn match. + */ + scanValuePatterns: CpsPiiValuePattern[]; + + /** + * Additional value-content regexes, merged with `scanValuePatterns` — the + * content counterpart to `extraKeyPatterns`. + */ + extraValuePatterns: RegExp[]; + + /** + * Application-supplied functions run on every string value, after all + * pattern-based scrubbing — an escape hatch for logic no regex can + * express. Runs independently of `scanValuePatterns`/`extraValuePatterns`. + * A throwing function is skipped (logged in dev mode), not fatal. + */ + extraValueTransforms: Array<(value: string) => string>; +} + +/** Keys whose values never leave the browser, matched case-insensitively (`Authorization`, `access_token`, `apiKey`, …). */ +const DENYLIST = + /pass(word|wd)?|secret|token|auth|credential|cookie|api[-_]?key|bearer|jwt|signature|session[-_]?key|ssn/i; + +/** Absolute http(s) URLs appearing anywhere inside a string. */ +const URL_PATTERN = /https?:\/\/[^\s"'<>]+/gi; + +/** + * A root-relative path with a query string or fragment. The lookbehind + * rejects a `/` that's mid-word (so this doesn't re-match inside an + * already-scrubbed URL) without requiring whitespace before the path — + * punctuation like `:` counts as a boundary too. + */ +const PATH_WITH_QUERY = /(?<![\w/])\/[^\s?#]*[?#][^\s]*/g; + +/** The redaction placeholder written in place of a denylisted value. */ +export const CPS_REDACTED = '[redacted]'; + +/** + * Built-in value-content patterns for {@link CpsRedactConfig.scanValuePatterns}, + * minus `'creditCard'`, which needs a Luhn checksum per match and is handled + * separately by {@link scrubCreditCards}. + */ +const VALUE_PATTERNS: Record< + Exclude<CpsPiiValuePattern, 'creditCard'>, + RegExp +> = { + // Common email shape, not full RFC 5322. + email: /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, + // Matches only the dashed `123-45-6789` format; a bare 9-digit run isn't + // reliably an SSN. + ssn: /\b\d{3}-\d{2}-\d{4}\b/g, + ipv4: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g, + // NANP (US/Canada): 3-3-4 digits, optional `1`/`+1` country code, + // South Africa (27), Botswana (267), Ghana (233), Kenya + // (254), Mauritius (230), Mozambique (258), Namibia (264), Seychelles + // (248), Tanzania (255), Uganda (256), Zambia (260). + phone: + /(?:(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}|(?:\+|00)(?:27|267|233|254|230|258|264|248|255|256|260)(?:[-.\s]?\d){7,9})\b/g +}; + +/** + * Fixed order for {@link CpsRedactConfig.scanValuePatterns}, independent of + * the consumer's array order. `creditCard` must run before `phone`: phone + * has no leading `\b` and can match a trailing substring of a card number, + * fragmenting and leaking part of it if it ran first. + */ +const VALUE_PATTERN_ORDER: CpsPiiValuePattern[] = [ + 'email', + 'creditCard', + 'ssn', + 'ipv4', + 'phone' +]; + +/** + * Payment card number length, per ISO/IEC 7812 — 13 digits at the short + * end (older Visa) through 19 at the long (Maestro and co.). + */ +const CARD_MIN_DIGITS = 13; +const CARD_MAX_DIGITS = 19; + +/** + * Candidate digit runs, allowing the separators people actually type. + * Built from the bounds above so the pattern and the length check below + * cannot drift apart. + */ +const CREDIT_CARD_CANDIDATE = new RegExp( + `\\b(?:\\d[ -]?){${CARD_MIN_DIGITS},${CARD_MAX_DIGITS}}\\b`, + 'g' +); + +/** The standard Luhn checksum — tells a real card number from an unrelated digit run of the same length. */ +function isLuhnValid(digits: string): boolean { + let sum = 0; + let alternate = false; + + for (let i = digits.length - 1; i >= 0; i--) { + let n = digits.charCodeAt(i) - 48; // '0' + if (alternate) { + n *= 2; + if (n > 9) { + n -= 9; + } + } + sum += n; + alternate = !alternate; + } + + return digits.length > 0 && sum % 10 === 0; +} + +/** Redacts credit-card-shaped digit runs that also pass a Luhn checksum. */ +function scrubCreditCards(value: string): string { + return value.replace(CREDIT_CARD_CANDIDATE, (match) => { + const digits = match.replace(/\D/g, ''); + return digits.length >= CARD_MIN_DIGITS && + digits.length <= CARD_MAX_DIGITS && + isLuhnValid(digits) + ? CPS_REDACTED + : match; + }); +} + +/** + * Memoizes the global copy `ensureGlobal` builds for a non-global pattern — + * config-level regexes are stable across many calls, so without this a + * fresh `RegExp` would be compiled on every scrubbed string, for the life + * of the page. Safe to reuse: every use is via `.replace()`, which resets + * `lastIndex` itself before scanning. + */ +const globalPatternCache = new WeakMap<RegExp, RegExp>(); + +/** Ensures a pattern has the `g` flag, so `.replace()` catches every occurrence, not just the first. */ +function ensureGlobal(pattern: RegExp): RegExp { + if (pattern.global) { + return pattern; + } + + let global = globalPatternCache.get(pattern); + if (!global) { + global = new RegExp(pattern.source, `${pattern.flags}g`); + globalPatternCache.set(pattern, global); + } + return global; +} + +/** + * Default redaction settings. Conservative; widen as needed. + * + * @group Utils + */ +export const CPS_DEFAULT_REDACT_CONFIG: CpsRedactConfig = { + extraKeyPatterns: [], + maxStringLength: 1024, + maxKeys: 50, + maxStackLength: 2048, + includeStack: true, + stripUrlQuery: true, + scanValuePatterns: [], + extraValuePatterns: [], + extraValueTransforms: [] +}; + +/** + * Effective redact config for a concern's own `redact: boolean` toggle + * (`CpsLogConfig.redact`, `CpsScenarioTelemetryConfig.redact`, `CpsBiTelemetryConfig.redact`). + * Disabling it skips only the configurable scrubbing — the built-in + * credential denylist, size caps, error normalization and + * `extraValueTransforms` stay on regardless. See DESIGN.md §10, "Turning + * redaction off per concern", for why. + * + * @param config the concern's resolved `CPS_REDACT_CONFIG` + * @param enabled the concern's own `redact` setting + * @returns `config` unchanged when enabled, otherwise with configurable + * scrubbing turned off + * + * @group Utils + */ +export function cpsRedactConfigFor( + config: CpsRedactConfig, + enabled: boolean +): CpsRedactConfig { + if (enabled) { + return config; + } + + return { + ...config, + extraKeyPatterns: [], + stripUrlQuery: false, + scanValuePatterns: [], + extraValuePatterns: [] + }; +} + +function isDenied(key: string, config: CpsRedactConfig): boolean { + if (DENYLIST.test(key)) { + return true; + } + return config.extraKeyPatterns.some((pattern) => { + // A global/sticky pattern is stateful: .test() advances lastIndex, so + // reset it to avoid alternating matches across calls. + pattern.lastIndex = 0; + return pattern.test(key); + }); +} + +/** Reduces a URL to `origin + pathname` — tokens and identifiers tend to live in the query/fragment being dropped. */ +function scrubUrl(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.origin}${parsed.pathname}`; + } catch { + const cut = url.search(/[?#]/); + return cut === -1 ? url : url.slice(0, cut); + } +} + +/** + * Strips URL query strings/fragments, scans for opted-in PII shapes, runs + * `extraValueTransforms`, then caps length — in that order, so value + * scanning also catches PII embedded in a URL path segment. + * + * @param value the string to clean + * @param config redaction settings + * @returns the cleaned, length-capped string + * + * @group Utils + */ +export function cpsScrubString(value: string, config: CpsRedactConfig): string { + let result = value; + + if (config.stripUrlQuery) { + result = result.replace(URL_PATTERN, (match) => scrubUrl(match)); + result = result.replace(PATH_WITH_QUERY, (match) => scrubUrl(match)); + } + + if (config.scanValuePatterns.length || config.extraValuePatterns.length) { + for (const name of VALUE_PATTERN_ORDER) { + if (!config.scanValuePatterns.includes(name)) { + continue; + } + result = + name === 'creditCard' + ? scrubCreditCards(result) + : result.replace(VALUE_PATTERNS[name], CPS_REDACTED); + } + for (const pattern of config.extraValuePatterns) { + result = result.replace(ensureGlobal(pattern), CPS_REDACTED); + } + } + + for (const transform of config.extraValueTransforms) { + try { + result = transform(result); + } catch (error) { + if (cpsIsDevMode()) { + // eslint-disable-next-line no-console + console.warn( + '[cps-telemetry] An extraValueTransforms function threw and was skipped', + error + ); + } + } + } + + if (result.length > config.maxStringLength) { + result = `${result.slice(0, config.maxStringLength)}…`; + } + + return result; +} + +/** + * Produces a telemetry-safe attribute bag: flat primitives only (enforcing + * at runtime what {@link CpsTelemetryMetadata} states at compile time), + * denylisted keys redacted, strings scrubbed via {@link cpsScrubString}, + * non-finite numbers and objects/functions/symbols dropped, and the + * attribute count capped (warns once via `console.warn` in dev mode). + * + * @param value the candidate attributes + * @param config redaction settings + * @returns a safe attribute bag, or `undefined` when nothing survives + * + * @group Utils + */ +export function cpsRedactMetadata( + value: unknown, + config: CpsRedactConfig = CPS_DEFAULT_REDACT_CONFIG +): CpsTelemetryMetadata | undefined { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + const result: CpsTelemetryMetadata = {}; + let kept = 0; + let truncated = false; + + for (const [key, raw] of Object.entries(value)) { + if (kept >= config.maxKeys) { + truncated = true; + break; + } + + if (isDenied(key, config)) { + result[key] = CPS_REDACTED; + kept++; + continue; + } + + if (raw === null || typeof raw === 'boolean') { + result[key] = raw; + kept++; + } else if (typeof raw === 'number') { + if (Number.isFinite(raw)) { + result[key] = raw; + kept++; + } + } else if (typeof raw === 'string') { + result[key] = cpsScrubString(raw, config); + kept++; + } + } + + if (truncated && cpsIsDevMode()) { + // eslint-disable-next-line no-console + console.warn( + `[cps-telemetry] Metadata exceeded maxKeys (${config.maxKeys}) and was truncated. Attributes past the limit were dropped silently in production.` + ); + } + + return kept > 0 ? result : undefined; +} + +/** + * Merges an already-redacted attribute bag into an existing one, + * re-applying `maxKeys` to the *combined* result. `cpsRedactMetadata` only + * bounds the bag passed to it in isolation — merging two independently + * capped bags without this can exceed the configured limit. A key already + * present in `target` is always updated (a replacement, not growth); only + * a genuinely new key counts against the cap, and is dropped once it's + * reached (warns once in dev mode, mirroring `cpsRedactMetadata`). + * + * Mutates and returns `target`. + * + * @param target the accumulator merged into, and returned + * @param incoming the bag being merged in, normally the output of {@link cpsRedactMetadata} + * @param config redaction settings + * @returns `target`, with `incoming` merged in + * + * @group Utils + */ +export function cpsMergeMetadata( + target: CpsTelemetryMetadata, + incoming: CpsTelemetryMetadata | undefined, + config: CpsRedactConfig = CPS_DEFAULT_REDACT_CONFIG +): CpsTelemetryMetadata { + if (!incoming) { + return target; + } + + let keyCount = Object.keys(target).length; + let truncated = false; + + for (const [key, value] of Object.entries(incoming)) { + const isNewKey = !(key in target); + if (isNewKey && keyCount >= config.maxKeys) { + truncated = true; + continue; + } + if (isNewKey) { + keyCount++; + } + target[key] = value; + } + + if (truncated && cpsIsDevMode()) { + // eslint-disable-next-line no-console + console.warn( + `[cps-telemetry] Combined metadata exceeded maxKeys (${config.maxKeys}) and was truncated. Attributes past the limit were dropped silently in production.` + ); + } + + return target; +} + +/** + * Converts a thrown value into a bounded, telemetry-safe shape — only the + * constructor name, scrubbed message and (optionally) a capped stack; never + * the raw error object. + * + * @param error any thrown value, including non-`Error` values + * @param config redaction settings + * @returns the normalized error, or `undefined` when there is nothing to report + * + * @group Utils + */ +export function cpsNormalizeError( + error: unknown, + config: CpsRedactConfig = CPS_DEFAULT_REDACT_CONFIG +): CpsTelemetryError | undefined { + if (error === null || error === undefined) { + return undefined; + } + + if (error instanceof Error) { + const normalized: CpsTelemetryError = { + name: error.name || 'Error', + message: cpsScrubString(error.message ?? '', config) + }; + + if (config.includeStack && typeof error.stack === 'string') { + normalized.stack = cpsScrubString(error.stack, { + ...config, + maxStringLength: config.maxStackLength + }); + } + + return normalized; + } + + if (typeof error === 'string') { + return { name: 'Error', message: cpsScrubString(error, config) }; + } + + if (isHttpErrorShaped(error)) { + // HttpErrorResponse doesn't extend Error, so instanceof above misses it. + return { + name: typeof error.name === 'string' ? error.name : 'HttpErrorResponse', + message: cpsScrubString(error.message, config) + }; + } + + return { + name: typeof error === 'object' ? 'UnknownError' : typeof error, + message: CPS_REDACTED + }; +} + +/** Duck-types an `HttpErrorResponse`-shaped object, avoiding an `@angular/common/http` import (and its runtime) just for this check. */ +function isHttpErrorShaped( + error: unknown +): error is { name?: unknown; message: string; status: number } { + if (error === null || typeof error !== 'object') { + return false; + } + const candidate = error as Record<string, unknown>; + return ( + typeof candidate.message === 'string' && + typeof candidate.status === 'number' && + ('statusText' in candidate || 'url' in candidate) + ); +} diff --git a/projects/cps-telemetry/src/lib/utils/cps-telemetry-safe.util/cps-telemetry-safe.util.spec.ts b/projects/cps-telemetry/src/lib/utils/cps-telemetry-safe.util/cps-telemetry-safe.util.spec.ts new file mode 100644 index 000000000..c539bab18 --- /dev/null +++ b/projects/cps-telemetry/src/lib/utils/cps-telemetry-safe.util/cps-telemetry-safe.util.spec.ts @@ -0,0 +1,365 @@ +import { isDevMode } from '@angular/core'; +import { + cpsDeepClone, + cpsElapsedNow, + cpsEpochToPerf, + cpsNow, + cpsSafe, + cpsSafeVoid, + cpsSafeVoidMaybeAsync, + cpsUuid +} from './cps-telemetry-safe.util'; + +jest.mock('@angular/core', () => ({ + ...jest.requireActual('@angular/core'), + isDevMode: jest.fn(() => true) +})); + +const isDevModeMock = isDevMode as jest.Mock; + +describe('cpsSafe', () => { + let consoleError: jest.SpyInstance; + + beforeEach(() => { + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + isDevModeMock.mockReturnValue(true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return the result when the operation succeeds', () => { + expect(cpsSafe('op', () => 42, 0)).toBe(42); + }); + + it('should return the fallback instead of throwing', () => { + const result = cpsSafe( + 'op', + () => { + throw new Error('boom'); + }, + 'fallback' + ); + expect(result).toBe('fallback'); + }); + + it('should never let a telemetry failure reach the caller', () => { + expect(() => + cpsSafeVoid('op', () => { + throw new Error('boom'); + }) + ).not.toThrow(); + }); + + it('should report the suppressed error in development mode', () => { + isDevModeMock.mockReturnValue(true); + cpsSafeVoid('scenario.step', () => { + throw new Error('boom'); + }); + expect(consoleError).toHaveBeenCalledWith( + '[cps-telemetry] scenario.step failed', + expect.any(Error) + ); + }); + + it('should stay silent in production mode', () => { + isDevModeMock.mockReturnValue(false); + cpsSafeVoid('scenario.step', () => { + throw new Error('boom'); + }); + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('should not rethrow even when the dev-mode report itself throws', () => { + isDevModeMock.mockImplementation(() => { + throw new Error('outside injection context'); + }); + expect(() => + cpsSafeVoid('op', () => { + throw new Error('boom'); + }) + ).not.toThrow(); + }); + + it('should not rethrow when console.error itself throws', () => { + consoleError.mockImplementation(() => { + throw new Error('console is patched and broken'); + }); + expect(() => + cpsSafeVoid('op', () => { + throw new Error('boom'); + }) + ).not.toThrow(); + }); +}); + +describe('cpsSafeVoidMaybeAsync', () => { + let consoleError: jest.SpyInstance; + + beforeEach(() => { + consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + isDevModeMock.mockReturnValue(true); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should behave exactly like cpsSafeVoid for a synchronous function', () => { + expect(() => + cpsSafeVoidMaybeAsync('op', () => { + throw new Error('boom'); + }) + ).not.toThrow(); + expect(consoleError).toHaveBeenCalledWith( + '[cps-telemetry] op failed', + expect.any(Error) + ); + }); + + it('should not throw for a function that returns void', () => { + expect(() => cpsSafeVoidMaybeAsync('op', () => undefined)).not.toThrow(); + }); + + it('should report a rejection from an async function typed as void', async () => { + const asyncFn = (async () => { + throw new Error('async boom'); + }) as () => void; + + cpsSafeVoidMaybeAsync('logApi.deliver', asyncFn); + + await Promise.resolve(); + await Promise.resolve(); + + expect(consoleError).toHaveBeenCalledWith( + '[cps-telemetry] logApi.deliver failed', + expect.any(Error) + ); + }); + + it('should not produce an unhandled rejection for a rejecting async function', async () => { + const asyncFn = (async () => { + throw new Error('unhandled if unguarded'); + }) as () => void; + + expect(() => cpsSafeVoidMaybeAsync('op', asyncFn)).not.toThrow(); + await Promise.resolve(); + await Promise.resolve(); + }); + + it('should stay silent in production mode for an async rejection', async () => { + isDevModeMock.mockReturnValue(false); + const asyncFn = (async () => { + throw new Error('async boom'); + }) as () => void; + + cpsSafeVoidMaybeAsync('op', asyncFn); + await Promise.resolve(); + await Promise.resolve(); + + expect(consoleError).not.toHaveBeenCalled(); + }); + + it('should not produce a new unhandled rejection when console.error itself throws', async () => { + const nodeProcess = ( + globalThis as unknown as { + process: { + on( + event: 'unhandledRejection', + listener: (reason: unknown) => void + ): void; + off( + event: 'unhandledRejection', + listener: (reason: unknown) => void + ): void; + }; + } + ).process; + + const unhandled = jest.fn(); + nodeProcess.on('unhandledRejection', unhandled); + consoleError.mockImplementation(() => { + throw new Error('console is patched and broken'); + }); + const asyncFn = (async () => { + throw new Error('async boom'); + }) as () => void; + + cpsSafeVoidMaybeAsync('op', asyncFn); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + + nodeProcess.off('unhandledRejection', unhandled); + expect(unhandled).not.toHaveBeenCalled(); + }); +}); + +describe('cpsDeepClone', () => { + it('should produce a deep copy that mutation of the original does not affect', () => { + const original = { a: 1, nested: { b: [1, 2, 3] } }; + const clone = cpsDeepClone(original); + + original.nested.b.push(4); + + expect(clone).toEqual({ a: 1, nested: { b: [1, 2, 3] } }); + expect(clone).not.toBe(original); + expect(clone.nested).not.toBe(original.nested); + }); + + it('should fall back to a JSON round-trip when structuredClone is unavailable', () => { + const realStructuredClone = globalThis.structuredClone; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).structuredClone = undefined; + + try { + expect(cpsDeepClone({ a: 1, nested: { b: 2 } })).toEqual({ + a: 1, + nested: { b: 2 } + }); + } finally { + globalThis.structuredClone = realStructuredClone; + } + }); + + it('should fall back to a JSON round-trip when structuredClone throws', () => { + const realStructuredClone = globalThis.structuredClone; + globalThis.structuredClone = () => { + throw new Error('cannot clone'); + }; + + try { + expect(cpsDeepClone({ a: 1 })).toEqual({ a: 1 }); + } finally { + globalThis.structuredClone = realStructuredClone; + } + }); +}); + +describe('cpsUuid', () => { + const realCrypto = globalThis.crypto; + + afterEach(() => { + Object.defineProperty(globalThis, 'crypto', { + value: realCrypto, + configurable: true + }); + }); + + const useCrypto = (value: unknown) => + Object.defineProperty(globalThis, 'crypto', { + value, + configurable: true + }); + + it('should produce a UUID-shaped identifier', () => { + expect(cpsUuid()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + ); + }); + + it('should produce distinct identifiers', () => { + const ids = new Set(Array.from({ length: 200 }, () => cpsUuid())); + expect(ids.size).toBe(200); + }); + + it('should fall back to getRandomValues when randomUUID is unavailable', () => { + useCrypto({ + getRandomValues: (array: Uint8Array) => { + for (let i = 0; i < array.length; i++) { + array[i] = i; + } + return array; + } + }); + + expect(cpsUuid()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + }); + + it('should fall back to Math.random when the Web Crypto API is absent', () => { + useCrypto(undefined); + + expect(cpsUuid()).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + ); + }); +}); + +describe('cpsNow', () => { + it('should return a number that does not go backwards', () => { + const first = cpsNow(); + const second = cpsNow(); + expect(typeof first).toBe('number'); + expect(second).toBeGreaterThanOrEqual(first); + }); +}); + +describe('cpsElapsedNow', () => { + it('should return a number that does not go backwards', () => { + const first = cpsElapsedNow(); + const second = cpsElapsedNow(); + expect(typeof first).toBe('number'); + expect(second).toBeGreaterThanOrEqual(first); + }); + + it('should read the same object jsdom exposes as globalThis.top', () => { + expect(globalThis.top).toBe(globalThis); + }); +}); + +describe('cpsEpochToPerf', () => { + const realPerformance = globalThis.performance; + + afterEach(() => { + Object.defineProperty(globalThis, 'performance', { + value: realPerformance, + configurable: true + }); + }); + + function usePerformance(timeOrigin: number, now: number) { + Object.defineProperty(globalThis, 'performance', { + value: { timeOrigin, now: () => now }, + configurable: true + }); + } + + it('should convert an epoch timestamp into the performance timeline', () => { + usePerformance(1_000_000, 5_000); + expect(cpsEpochToPerf(1_002_000)).toBe(2_000); + }); + + it('should accept the exact page origin', () => { + usePerformance(1_000_000, 5_000); + expect(cpsEpochToPerf(1_000_000)).toBe(0); + }); + + it('should reject a timestamp from before the page loaded', () => { + usePerformance(1_000_000, 5_000); + expect(cpsEpochToPerf(999_000)).toBeUndefined(); + }); + + it('should reject a timestamp in the future', () => { + usePerformance(1_000_000, 5_000); + expect(cpsEpochToPerf(1_010_000)).toBeUndefined(); + }); + + it.each([[NaN], [Infinity], [-Infinity]])( + 'should reject the non-finite input %p', + (input) => { + usePerformance(1_000_000, 5_000); + expect(cpsEpochToPerf(input)).toBeUndefined(); + } + ); + + it('should return undefined without a usable performance object', () => { + Object.defineProperty(globalThis, 'performance', { + value: undefined, + configurable: true + }); + expect(cpsEpochToPerf(1_000)).toBeUndefined(); + }); +}); diff --git a/projects/cps-telemetry/src/lib/utils/cps-telemetry-safe.util/cps-telemetry-safe.util.ts b/projects/cps-telemetry/src/lib/utils/cps-telemetry-safe.util/cps-telemetry-safe.util.ts new file mode 100644 index 000000000..af4d3e97a --- /dev/null +++ b/projects/cps-telemetry/src/lib/utils/cps-telemetry-safe.util/cps-telemetry-safe.util.ts @@ -0,0 +1,205 @@ +import { isPlatformBrowser } from '@angular/common'; +import { inject, isDevMode, PLATFORM_ID } from '@angular/core'; + +/** + * Runs a telemetry operation without letting it affect the application. + * + * Every public entry point in this library routes through this wrapper — + * it never rethrows. In development the caught error is reported to the + * console; in production nothing is printed. + */ +export function cpsSafe<T>(operation: string, fn: () => T, fallback: T): T { + try { + return fn(); + } catch (error) { + reportSuppressed(operation, error); + return fallback; + } +} + +/** + * Void-returning form of {@link cpsSafe}, for the common case of an emit call + * whose result nobody reads. + */ +export function cpsSafeVoid(operation: string, fn: () => void): void { + cpsSafe(operation, fn, undefined); +} + +/** + * Like {@link cpsSafeVoid}, but also catches a rejection from a `void`-typed + * extension point whose real implementation turns out to be `async` — an + * application-supplied provider hook, for instance. TypeScript accepts an + * `async` function wherever `() => void` is expected and discards the + * promise, so a plain `try`/`catch` can't see it reject. This checks for a + * thenable result and attaches a rejection handler. + * + * Not the default for every call: most `cpsSafeVoid` calls wrap this + * library's own synchronous code, where the extra check is pure overhead. + * Use this only for a function whose shape this library merely declares. + */ +export function cpsSafeVoidMaybeAsync(operation: string, fn: () => void): void { + cpsSafe( + operation, + () => { + const result: unknown = fn(); + if ( + result !== null && + typeof result === 'object' && + typeof (result as { then?: unknown }).then === 'function' + ) { + Promise.resolve(result as PromiseLike<unknown>).catch((error) => + reportSuppressed(operation, error) + ); + } + }, + undefined + ); +} + +/** `isDevMode()` throws outside an Angular context, so this guards that once for every caller. */ +export function cpsIsDevMode(): boolean { + try { + return isDevMode(); + } catch { + return false; + } +} + +/** + * Whether this code is running in a browser, as opposed to a server-side + * render. Must be called from an injection context, same as + * `inject(PLATFORM_ID)` itself requires. + */ +export function cpsIsBrowser(): boolean { + return isPlatformBrowser(inject(PLATFORM_ID)); +} + +/** + * Deep-clones a plain-data value. Prefers `structuredClone`; falls back to + * a JSON round-trip where it's unavailable (e.g. under jsdom) or throws. + */ +export function cpsDeepClone<T>(value: T): T { + if (typeof structuredClone === 'function') { + try { + return structuredClone(value); + } catch { + // fall through to the JSON fallback + } + } + return JSON.parse(JSON.stringify(value)) as T; +} + +function reportSuppressed(operation: string, error: unknown): void { + if (!cpsIsDevMode()) { + return; + } + try { + // eslint-disable-next-line no-console + console.error(`[cps-telemetry] ${operation} failed`, error); + } catch { + // A patched/throwing console must never escape telemetry suppression. + } +} + +/** + * Fills a byte array from the best available source. `crypto.getRandomValues` + * works on plain http:// too, where `crypto.randomUUID` doesn't. `Math.random` + * is the last-resort fallback for an environment with no Web Crypto at all. + */ +function randomBytes(count: number): Uint8Array { + const bytes = new Uint8Array(count); + const cryptoRef = globalThis.crypto; + + if (typeof cryptoRef?.getRandomValues === 'function') { + return cryptoRef.getRandomValues(bytes); + } + + for (let i = 0; i < count; i++) { + bytes[i] = (Math.random() * 256) | 0; + } + return bytes; +} + +/** Lowercase hex encoding, two characters per byte. */ +function toHex(bytes: Uint8Array): string { + return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** + * Generates a correlation identifier. Prefers `crypto.randomUUID`, falling + * back to formatting random bytes as a v4 UUID. + */ +export function cpsUuid(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + + const bytes = randomBytes(16); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 + + const hex = toHex(bytes); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20) + ].join('-'); +} + +/** + * Monotonic-ish millisecond clock for duration measurement. + * `performance.now()` is preferred since it's immune to wall-clock + * adjustments. + */ +export function cpsNow(): number { + const perf = globalThis.performance; + return typeof perf?.now === 'function' ? perf.now() : Date.now(); +} + +/** + * Milliseconds since the *host* page loaded — prefers `top.performance.now()` + * over the local realm's own, the same reasoning `cps-user-timings-internal.util.ts` + * applies to marks/measures: in a composed page, a fragment runs in its own + * same-origin iframe with a later `timeOrigin`, so a timeline position taken + * from its own `performance` doesn't line up against the shell's. Reading + * `top` always succeeds, even cross-origin; only touching a property on it + * can throw, in which case this falls back to {@link cpsNow}. + */ +export function cpsElapsedNow(): number { + try { + const top = globalThis.top; + if (typeof top?.performance?.now === 'function') { + return top.performance.now(); + } + } catch { + // cross-origin, or sandboxed without `allow-same-origin` + } + return cpsNow(); +} + +/** + * Converts an epoch timestamp into the `performance.now()` timeline. + * Clamped to the current page's lifetime — a future timestamp, or one from + * before the page loaded, yields `undefined`. + */ +export function cpsEpochToPerf(epochMs: number): number | undefined { + if (!Number.isFinite(epochMs)) { + return undefined; + } + + const perf = globalThis.performance; + if (typeof perf?.now !== 'function' || typeof perf.timeOrigin !== 'number') { + return undefined; + } + + const converted = epochMs - perf.timeOrigin; + const now = perf.now(); + + if (converted < 0 || converted > now) { + return undefined; + } + + return converted; +} diff --git a/projects/cps-telemetry/src/lib/utils/cps-user-timings-internal.util/cps-user-timings-internal.util.spec.ts b/projects/cps-telemetry/src/lib/utils/cps-user-timings-internal.util/cps-user-timings-internal.util.spec.ts new file mode 100644 index 000000000..083e5638a --- /dev/null +++ b/projects/cps-telemetry/src/lib/utils/cps-user-timings-internal.util/cps-user-timings-internal.util.spec.ts @@ -0,0 +1,161 @@ +import { + cpsClearMarks, + cpsMark, + cpsMarkName, + cpsMeasure +} from './cps-user-timings-internal.util'; + +/** + * jsdom implements `performance.now` and `timeOrigin` but neither `mark` nor + * `measure`, so the API has to be installed to test the supported path — and + * the unsupported path is simply the default. + */ +function installUserTimingApi(overrides: Record<string, unknown> = {}) { + const api = { + mark: jest.fn(), + measure: jest.fn(), + clearMarks: jest.fn(), + clearMeasures: jest.fn(), + getEntriesByName: jest.fn(() => []), + now: () => 0, + timeOrigin: 0, + ...overrides + }; + + Object.defineProperty(globalThis, 'performance', { + value: api, + configurable: true + }); + + return api; +} + +describe('cps-user-timings', () => { + const realPerformance = globalThis.performance; + + afterEach(() => { + Object.defineProperty(globalThis, 'performance', { + value: realPerformance, + configurable: true + }); + jest.restoreAllMocks(); + }); + + describe('cpsMarkName', () => { + it('should namespace the entry and include the scenario id', () => { + expect(cpsMarkName('shop', 'load-data', 'abc-123', 'fetch')).toBe( + 'shop:load-data:fetch:abc-123' + ); + }); + + it('should namespace by application, not by this library', () => { + expect(cpsMarkName('checkout', 'load', 'id-1', 'start')).toBe( + 'checkout:load:start:id-1' + ); + }); + + it('should keep two applications apart on one composed page', () => { + expect(cpsMarkName('shell', 'load', 'id-1', 'start')).not.toBe( + cpsMarkName('cart', 'load', 'id-1', 'start') + ); + }); + + it('should distinguish concurrent scenarios of the same name', () => { + expect(cpsMarkName('shop', 'load', 'id-1', 'start')).not.toBe( + cpsMarkName('shop', 'load', 'id-2', 'start') + ); + }); + }); + + describe('when the browser has no User Timing API', () => { + it('should no-op rather than throw', () => { + expect(() => cpsMark('m')).not.toThrow(); + expect(() => cpsMeasure('name', 'a', 'b')).not.toThrow(); + expect(() => cpsClearMarks(['a', 'b'])).not.toThrow(); + }); + + it('should touch nothing when the API is only partly present', () => { + const api = installUserTimingApi({ measure: undefined }); + + expect(() => cpsMark('m')).not.toThrow(); + expect(api.mark).not.toHaveBeenCalled(); + }); + }); + + describe('when the browser supports User Timing', () => { + it('should record a mark', () => { + const api = installUserTimingApi(); + cpsMark('cps:load:start:id'); + expect(api.mark).toHaveBeenCalledWith('cps:load:start:id'); + }); + + it('should record a measure between two marks', () => { + const api = installUserTimingApi(); + cpsMeasure('load [fetch]', 'start-mark', 'end-mark'); + expect(api.measure).toHaveBeenCalledWith( + 'load [fetch]', + 'start-mark', + 'end-mark' + ); + }); + + it('should drop the measure entry once created, to bound the buffer', () => { + const api = installUserTimingApi(); + cpsMeasure('load', 'a', 'b'); + expect(api.clearMeasures).toHaveBeenCalledWith('load'); + }); + + it('should swallow a measure against a missing mark', () => { + const api = installUserTimingApi({ + measure: jest.fn(() => { + throw new SyntaxError("mark 'a' does not exist"); + }) + }); + + expect(() => cpsMeasure('load', 'a', 'b')).not.toThrow(); + expect(api.clearMeasures).not.toHaveBeenCalled(); + }); + + it('should swallow a throwing mark', () => { + installUserTimingApi({ + mark: jest.fn(() => { + throw new Error('quota exceeded'); + }) + }); + expect(() => cpsMark('m')).not.toThrow(); + }); + + it('should clear every named mark', () => { + const api = installUserTimingApi(); + cpsClearMarks(['a', 'b', 'c']); + + expect(api.clearMarks).toHaveBeenCalledTimes(3); + expect(api.clearMarks).toHaveBeenCalledWith('a'); + expect(api.clearMarks).toHaveBeenCalledWith('c'); + }); + + it('should keep clearing after one mark throws', () => { + const api = installUserTimingApi({ + clearMarks: jest.fn((name: string) => { + if (name === 'a') { + throw new Error('gone'); + } + }) + }); + + expect(() => cpsClearMarks(['a', 'b'])).not.toThrow(); + expect(api.clearMarks).toHaveBeenCalledTimes(2); + }); + }); + + describe('resolving the host performance object (see DESIGN.md §13)', () => { + // jsdom's `globalThis.top` is a non-configurable accessor always equal + // to `globalThis` itself, so a divergent `top` (a real fragment under a + // different frame) can't be constructed here — every mark/measure/clear + // test above already exercises the `top.performance` branch, just with + // `top` equal to `globalThis`. + it('should read the same object jsdom exposes as globalThis.top', () => { + expect(globalThis.top).toBe(globalThis); + }); + }); +}); diff --git a/projects/cps-telemetry/src/lib/utils/cps-user-timings-internal.util/cps-user-timings-internal.util.ts b/projects/cps-telemetry/src/lib/utils/cps-user-timings-internal.util/cps-user-timings-internal.util.ts new file mode 100644 index 000000000..08bd78e10 --- /dev/null +++ b/projects/cps-telemetry/src/lib/utils/cps-user-timings-internal.util/cps-user-timings-internal.util.ts @@ -0,0 +1,123 @@ +/** + * User Timing helpers, so scenarios show up in the browser's own performance + * tooling, lined up against paint, layout, script and network on the + * DevTools **Performance → Timings** track. + * + * Every function here feature-detects before touching the API — jsdom + * implements `performance.now` but neither `mark` nor `measure`, and older + * Safari lacks `measure` overloads too. + */ + +interface UserTimingApi { + mark(name: string): void; + measure(name: string, start: string, end: string): void; + clearMarks(name?: string): void; + clearMeasures(name?: string): void; +} + +/** Returns the User Timing API when the browser provides a usable one. */ +function api(): UserTimingApi | undefined { + const perf = hostPerformance(); + + if ( + typeof perf?.mark !== 'function' || + typeof perf.measure !== 'function' || + typeof perf.clearMarks !== 'function' || + typeof perf.clearMeasures !== 'function' + ) { + return undefined; + } + + return perf as UserTimingApi; +} + +/** + * The `performance` object marks and measures actually get written to. + * + * Prefers `top.performance` over the realm's own: in a composed page, a + * fragment runs in its own same-origin iframe, and writing to its own + * `performance` puts every entry on a timeline DevTools never has open. + * Reading `top` always succeeds, even cross-origin; only touching a + * property on it can throw, in which case this falls back to the realm's + * own `performance`. + */ +function hostPerformance(): Partial<UserTimingApi> | undefined { + try { + const top = globalThis.top; + if (top?.performance) { + return top.performance as Partial<UserTimingApi>; + } + } catch { + // cross-origin, or sandboxed without `allow-same-origin` + } + return globalThis.performance as Partial<UserTimingApi> | undefined; +} + +/** + * Builds the mark name for one scenario boundary. Includes the scenario id + * so concurrent scenarios of the same name don't measure against each + * other's marks, and the application's name so entries from different + * fragments in a composed page stay distinguishable. + */ +export function cpsMarkName( + application: string, + scenarioName: string, + scenarioId: string, + step: string +): string { + return `${application}:${scenarioName}:${step}:${scenarioId}`; +} + +/** Records a mark, if the browser supports User Timing. */ +export function cpsMark(name: string): void { + try { + api()?.mark(name); + } catch { + // never let instrumentation affect the caller + } +} + +/** + * Records a measure between two marks and then drops the entry. `measure()` + * emits its trace event at call time, so removing the entry afterwards + * keeps the DevTools timeline intact while stopping the entry buffer from + * growing without bound. + * + * Silently does nothing when either mark is missing. + */ +export function cpsMeasure( + name: string, + startMark: string, + endMark: string +): void { + const perf = api(); + if (!perf) { + return; + } + + try { + perf.measure(name, startMark, endMark); + perf.clearMeasures(name); + } catch { + // a referenced mark doesn't exist + } +} + +/** + * Removes the given marks from the entry buffer. Called once a scenario + * settles, so marks don't accumulate for the life of the page. + */ +export function cpsClearMarks(names: readonly string[]): void { + const perf = api(); + if (!perf) { + return; + } + + for (const name of names) { + try { + perf.clearMarks(name); + } catch { + // entry already gone + } + } +} diff --git a/projects/cps-telemetry/src/public-api.ts b/projects/cps-telemetry/src/public-api.ts new file mode 100644 index 000000000..b2da2f32b --- /dev/null +++ b/projects/cps-telemetry/src/public-api.ts @@ -0,0 +1,133 @@ +/* + * Public API Surface of cps-telemetry + * + * Covers two audiences: an application using telemetry, and an author + * writing a custom sink or log transport. + */ + +// Configuration +export { + CPS_DEFAULT_TELEMETRY_CONFIG, + CPS_REDACT_CONFIG, + CPS_TELEMETRY_IDENTITY, + CpsTelemetryIdentity +} from './lib/config/cps-telemetry-common.config/cps-telemetry-common.config'; +export { + CPS_BI_TELEMETRY_CONFIG, + CpsBiTelemetryConfig +} from './lib/config/cps-bi-telemetry.config/cps-bi-telemetry.config'; +export { + CPS_LOG_CONFIG, + CpsLogConfig +} from './lib/config/cps-log.config/cps-log.config'; +export { + CPS_SCENARIO_TELEMETRY_CONFIG, + CpsScenarioTelemetryConfig +} from './lib/config/cps-scenario-telemetry.config/cps-scenario-telemetry.config'; + +// Providers +export { + CpsTelemetryFeature, + CpsTelemetryLocalSinkMode, + provideCpsTelemetry, + provideCpsTelemetryBroadcastHost, + provideCpsTelemetrySink, + withBiEvents, + withLogging, + withRedaction, + withScenarios +} from './lib/providers/cps-telemetry-common.providers/cps-telemetry-common.providers'; +export { + CPS_LOG_API_PROVIDER, + CpsLogApiProvider, + CpsLogQuery +} from './lib/providers/cps-log-api.provider/cps-log-api.provider'; +export { CpsNoopLogApiProvider } from './lib/providers/cps-noop-log-api.provider/cps-noop-log-api.provider'; +export { CpsBroadcastLogApiProvider } from './lib/providers/cps-broadcast-log-api.provider/cps-broadcast-log-api.provider'; + +// Models +export { + CpsBiEvent, + CpsBiEventDetail, + CpsBiEventName, + CpsBiEventNames +} from './lib/models/cps-bi.models/cps-bi.models'; +export { + CPS_LOG_LEVEL_ORDER, + CpsLogger, + CpsLoggerName, + CpsLoggerNames, + CpsLogDetail, + CpsLogLevel, + CpsLogRecord +} from './lib/models/cps-log.models/cps-log.models'; +export { + CpsScenarioAggregate, + CpsScenarioName, + CpsScenarioNames, + CpsScenarioOptions, + CpsScenarioOutcome, + CpsScenarioRecord, + CpsScenarioStatus, + CpsScenarioStep, + CpsScenarioStepDetail, + CpsScenarioStepEvent, + CpsScenarioStepStatus, + CpsScenarioSteps, + CpsStepName +} from './lib/models/cps-scenario.models/cps-scenario.models'; +export { + CPS_DEFAULT_EVENT_NAMESPACE, + CPS_TELEMETRY_EVENT_TYPE, + cpsEventTypes, + CpsTelemetryError, + CpsTelemetryEventTypes, + CpsTelemetryMetadata +} from './lib/models/cps-telemetry-common.models/cps-telemetry-common.models'; +export { + CpsJsonObject, + CpsJsonValue, + CpsTelemetryDestination, + CpsTelemetryEventKind, + CpsTelemetryEventOrigin, + CpsTelemetryObservedEvent, + CpsTelemetryPublishInput +} from './lib/models/cps-telemetry-monitor.models/cps-telemetry-monitor.models'; + +// Services +export { CpsBiTelemetryService } from './lib/services/cps-bi-telemetry.service/cps-bi-telemetry.service'; +export { CpsLoggerService } from './lib/services/cps-logger.service/cps-logger.service'; +export { CpsScenario } from './lib/scenario/cps-scenario/cps-scenario'; +export { CpsTelemetryMonitor } from './lib/services/cps-telemetry-monitor.service/cps-telemetry-monitor.service'; +export { CpsScenarioTelemetryService } from './lib/services/cps-scenario-telemetry.service/cps-scenario-telemetry.service'; + +// Sinks +export { CpsTelemetryBroadcastHost } from './lib/sinks/cps-broadcast/cps-broadcast-host.service'; +export { CpsBroadcastTelemetrySink } from './lib/sinks/cps-broadcast/cps-broadcast-telemetry.sink'; +export { + CPS_BROADCAST_CHANNEL, + CPS_DEFAULT_BROADCAST_CHANNEL +} from './lib/sinks/cps-broadcast/cps-broadcast.messages'; +export { CpsTelemetrySink } from './lib/sinks/cps-telemetry/cps-telemetry-abstract.sink/cps-telemetry-abstract.sink'; +export { CpsNoopTelemetrySink } from './lib/sinks/cps-telemetry/cps-noop-telemetry.sink/cps-noop-telemetry.sink'; + +// Utilities an application or a custom sink needs +export { + CpsTraceScenarioOptions, + traceScenario +} from './lib/scenario/cps-scenario-operators/cps-scenario-operators'; +export { + CpsDebugFlag, + cpsIsDebugEnabled +} from './lib/utils/cps-debug-flag.util/cps-debug-flag.util'; +export { cpsUuid } from './lib/utils/cps-telemetry-safe.util/cps-telemetry-safe.util'; +export { + CPS_DEFAULT_REDACT_CONFIG, + CPS_REDACTED, + CpsPiiValuePattern, + CpsRedactConfig, + cpsNormalizeError, + cpsRedactConfigFor, + cpsRedactMetadata, + cpsScrubString +} from './lib/utils/cps-telemetry-redact.util/cps-telemetry-redact.util'; diff --git a/projects/cps-telemetry/telemetry-trace-live.txt b/projects/cps-telemetry/telemetry-trace-live.txt new file mode 100644 index 000000000..63f431d25 --- /dev/null +++ b/projects/cps-telemetry/telemetry-trace-live.txt @@ -0,0 +1,1406 @@ +Generated 2026-09-05T11:11:25.069Z from a live Playwright session against +the running composition dev server (localhost:4200) — real UI, real +library code, debugLogger/debugScenario/debugBI enabled in localStorage. +Each entry's payload is the exact second console argument the library +prints on that debug line (the literal object it would hand a sink). + +Not reproduced live in this run (kept out rather than faked): +route-navigation abandoned/superseded/timeout (real lazy chunks load +too fast to race without artificial network stalling), and the second +autocomplete-search superseded attempt (landed as a clean success — +see the trigger note on that entry). Entirely absent, by design, same +as in the Jest-derived report: incomplete, exceededStepsLimit, +mirrorErrorsToRum, redaction/minLevel observations, autocomplete- +validate failure — composition has no real trigger for any of these. + +28 scenario/BI entries, 1 log entries. + +---------------------------------------------------------------------- +[1/28] SCENARIO route-navigation status=success delta=68ms +trigger: initial page load: goto /checkbox/examples?experimental=true + +{ + "scenarioId": "90582196-0349-4227-8f65-59671ed4a50e", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/checkbox/examples", + "status": "success", + "startTime": "2026-09-05T11:11:01.595Z", + "endTime": "2026-09-05T11:11:01.663Z", + "delta": 68, + "elapsed": 428, + "stepCount": 2, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 359, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 1, + "endOffset": 68, + "stepDelta": 67, + "elapsed": 427, + "status": "success" + }, + { + "name": "activate", + "startOffset": 68, + "metadata": { + "finalUrl": "/checkbox/examples" + }, + "endOffset": 69, + "stepDelta": 1, + "elapsed": 428, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 68, + "endOffset": 68, + "stepDelta": 0, + "elapsed": 428, + "status": "success", + "metadata": { + "finalUrl": "/checkbox/examples" + } + } + ], + "previousStep": "activate", + "metadata": { + "finalUrl": "/checkbox/examples" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[2/28] SCENARIO route-navigation status=success delta=54ms +trigger: clicked the "Table" sidebar link + +{ + "scenarioId": "2705c3f3-a705-4e7b-8aea-3a1706a9ea3b", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/table", + "status": "success", + "startTime": "2026-09-05T11:11:02.593Z", + "endTime": "2026-09-05T11:11:02.647Z", + "delta": 54, + "elapsed": 1411, + "stepCount": 3, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 1357, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 1, + "endOffset": 3, + "stepDelta": 2, + "elapsed": 1360, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 3, + "endOffset": 54, + "stepDelta": 51, + "elapsed": 1411, + "status": "success" + }, + { + "name": "activate", + "startOffset": 54, + "metadata": { + "finalUrl": "/table/examples" + }, + "endOffset": 54, + "stepDelta": 0, + "elapsed": 1411, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 54, + "endOffset": 54, + "stepDelta": 0, + "elapsed": 1411, + "status": "success", + "metadata": { + "finalUrl": "/table/examples" + } + } + ], + "previousStep": "activate", + "metadata": { + "finalUrl": "/table/examples" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[3/28] SCENARIO table-page-load status=success delta=639ms +trigger: activated the "Table 11" tab (lazyLoadOnInit fires the first lazy load) + +{ + "scenarioId": "2fe1c6a7-d33a-40ca-836f-eb1e841f07fa", + "scenarioName": "table-page-load", + "feature": "table", + "status": "success", + "startTime": "2026-09-05T11:11:02.864Z", + "endTime": "2026-09-05T11:11:03.503Z", + "delta": 639, + "elapsed": 2267, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 1628, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "metadata": { + "first": 0, + "rows": 10, + "totalRecords": 1001 + }, + "endOffset": 639, + "stepDelta": 639, + "elapsed": 2267, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 639, + "endOffset": 639, + "stepDelta": 0, + "elapsed": 2267, + "status": "success", + "metadata": { + "first": 0, + "rows": 10, + "totalRecords": 1001 + } + } + ], + "previousStep": "fetch", + "aggregates": [ + { + "name": "format-row", + "elapsed": 0, + "callCount": 10 + } + ], + "metadata": { + "first": 0, + "rows": 10, + "totalRecords": 1001 + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[4/28] SCENARIO table-page-load status=abandoned delta=62ms +trigger: clicked the paginator’s next-page button — superseded by the click right after it + +{ + "scenarioId": "250514ab-eb44-4783-81a3-3b2d88dba515", + "scenarioName": "table-page-load", + "feature": "table", + "status": "abandoned", + "startTime": "2026-09-05T11:11:04.211Z", + "endTime": "2026-09-05T11:11:04.273Z", + "delta": 62, + "elapsed": 3037, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2975, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 1, + "metadata": { + "abandonedBy": "caller" + }, + "endOffset": 62, + "stepDelta": 61, + "elapsed": 3037, + "status": "abandoned", + "reason": "superseded" + }, + { + "name": "scenario-end", + "startOffset": 62, + "endOffset": 62, + "stepDelta": 0, + "elapsed": 3037, + "status": "abandoned", + "reason": "superseded", + "metadata": { + "abandonedBy": "caller" + } + } + ], + "previousStep": "fetch", + "metadata": { + "abandonedBy": "caller" + }, + "application": "composition", + "reason": "superseded" +} + +---------------------------------------------------------------------- +[5/28] SCENARIO table-page-load status=success delta=603ms +trigger: the paginator click that superseded the one above, settling on its own + +{ + "scenarioId": "f5931eb1-db32-458f-9682-9c82c7f556ab", + "scenarioName": "table-page-load", + "feature": "table", + "status": "success", + "startTime": "2026-09-05T11:11:04.274Z", + "endTime": "2026-09-05T11:11:04.877Z", + "delta": 603, + "elapsed": 3642, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 3038, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "metadata": { + "first": 20, + "rows": 10, + "totalRecords": 1001 + }, + "endOffset": 604, + "stepDelta": 604, + "elapsed": 3641, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 603, + "endOffset": 603, + "stepDelta": 0, + "elapsed": 3641, + "status": "success", + "metadata": { + "first": 20, + "rows": 10, + "totalRecords": 1001 + } + } + ], + "previousStep": "fetch", + "aggregates": [ + { + "name": "format-row", + "elapsed": 0, + "callCount": 10 + } + ], + "metadata": { + "first": 20, + "rows": 10, + "totalRecords": 1001 + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[6/28] SCENARIO table-page-load status=abandoned delta=136ms +trigger: clicked the paginator again, then navigated away before its 600ms load finished + +{ + "scenarioId": "e9a7d502-bf69-449a-a563-9388b1e116c6", + "scenarioName": "table-page-load", + "feature": "table", + "status": "abandoned", + "startTime": "2026-09-05T11:11:05.310Z", + "endTime": "2026-09-05T11:11:05.446Z", + "delta": 136, + "elapsed": 4210, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 4074, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "metadata": { + "abandonedBy": "caller" + }, + "endOffset": 136, + "stepDelta": 136, + "elapsed": 4210, + "status": "abandoned", + "reason": "component-destroyed" + }, + { + "name": "scenario-end", + "startOffset": 136, + "endOffset": 136, + "stepDelta": 0, + "elapsed": 4210, + "status": "abandoned", + "reason": "component-destroyed", + "metadata": { + "abandonedBy": "caller" + } + } + ], + "previousStep": "fetch", + "metadata": { + "abandonedBy": "caller" + }, + "application": "composition", + "reason": "component-destroyed" +} + +---------------------------------------------------------------------- +[7/28] SCENARIO route-navigation status=success delta=32ms +trigger: clicked the "File upload" sidebar link (also destroys the table load above) + +{ + "scenarioId": "be4e412b-7e9c-4f13-a0d1-fa6b039fb432", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/file-upload", + "status": "success", + "startTime": "2026-09-05T11:11:05.418Z", + "endTime": "2026-09-05T11:11:05.450Z", + "delta": 32, + "elapsed": 4214, + "stepCount": 3, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 4182, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 1, + "endOffset": 2, + "stepDelta": 1, + "elapsed": 4185, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 2, + "endOffset": 32, + "stepDelta": 30, + "elapsed": 4214, + "status": "success" + }, + { + "name": "activate", + "startOffset": 32, + "metadata": { + "finalUrl": "/file-upload/examples" + }, + "endOffset": 32, + "stepDelta": 0, + "elapsed": 4214, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 32, + "endOffset": 32, + "stepDelta": 0, + "elapsed": 4214, + "status": "success", + "metadata": { + "finalUrl": "/file-upload/examples" + } + } + ], + "previousStep": "activate", + "metadata": { + "finalUrl": "/file-upload/examples" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[8/28] SCENARIO file-upload status=success delta=3009ms +trigger: dropped a .png onto the "extra info" file-upload widget + +{ + "scenarioId": "b4add684-3fe4-41a5-a244-70a48db2b084", + "scenarioName": "file-upload", + "feature": "file-upload", + "operation": "process", + "status": "success", + "startTime": "2026-09-05T11:11:05.882Z", + "endTime": "2026-09-05T11:11:08.891Z", + "delta": 3009, + "elapsed": 7655, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 4646, + "status": "success" + }, + { + "name": "process", + "startOffset": 0, + "metadata": { + "fileSize": 8 + }, + "endOffset": 3009, + "stepDelta": 3009, + "elapsed": 7655, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 3009, + "endOffset": 3009, + "stepDelta": 0, + "elapsed": 7655, + "status": "success", + "metadata": { + "fileSize": 8 + } + } + ], + "previousStep": "process", + "metadata": { + "fileSize": 8 + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[9/28] SCENARIO file-upload status=failure delta=501ms +trigger: dropped a .png onto the always-failing file-upload widget + +{ + "scenarioId": "9f97903c-762e-4ca9-a420-edf6e300b670", + "scenarioName": "file-upload", + "feature": "file-upload", + "operation": "process-failing", + "status": "failure", + "startTime": "2026-09-05T11:11:09.193Z", + "endTime": "2026-09-05T11:11:09.694Z", + "delta": 501, + "elapsed": 8459, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 7957, + "status": "success" + }, + { + "name": "process", + "startOffset": 0, + "endOffset": 501, + "stepDelta": 501, + "elapsed": 8459, + "status": "failure" + }, + { + "name": "scenario-end", + "startOffset": 501, + "endOffset": 501, + "stepDelta": 0, + "elapsed": 8459, + "status": "failure" + } + ], + "previousStep": "process", + "application": "composition" +} + +---------------------------------------------------------------------- +[10/28] SCENARIO file-upload status=abandoned delta=341ms +trigger: dropped a .png onto the "extra info" widget, then clicked its cancel button mid-flight + +{ + "scenarioId": "09713e01-881c-4efb-8a79-1eff4c391533", + "scenarioName": "file-upload", + "feature": "file-upload", + "operation": "process", + "status": "abandoned", + "startTime": "2026-09-05T11:11:10.010Z", + "endTime": "2026-09-05T11:11:10.351Z", + "delta": 341, + "elapsed": 9114, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 8774, + "status": "success" + }, + { + "name": "process", + "startOffset": 0, + "metadata": { + "abandonedBy": "caller" + }, + "endOffset": 341, + "stepDelta": 341, + "elapsed": 9114, + "status": "abandoned", + "reason": "user-cancelled" + }, + { + "name": "scenario-end", + "startOffset": 341, + "endOffset": 341, + "stepDelta": 0, + "elapsed": 9114, + "status": "abandoned", + "reason": "user-cancelled", + "metadata": { + "abandonedBy": "caller" + } + } + ], + "previousStep": "process", + "metadata": { + "abandonedBy": "caller" + }, + "application": "composition", + "reason": "user-cancelled" +} + +---------------------------------------------------------------------- +[11/28] SCENARIO file-upload status=abandoned delta=358ms +trigger: dropped a .png onto the "extra info" widget, then navigated away before it finished + +{ + "scenarioId": "74487bac-1559-41d4-b373-a7c6400f3c58", + "scenarioName": "file-upload", + "feature": "file-upload", + "operation": "process", + "status": "abandoned", + "startTime": "2026-09-05T11:11:10.662Z", + "endTime": "2026-09-05T11:11:11.020Z", + "delta": 358, + "elapsed": 9784, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 9426, + "status": "success" + }, + { + "name": "process", + "startOffset": 0, + "metadata": { + "abandonedBy": "caller" + }, + "endOffset": 358, + "stepDelta": 358, + "elapsed": 9784, + "status": "abandoned", + "reason": "component-destroyed" + }, + { + "name": "scenario-end", + "startOffset": 358, + "endOffset": 358, + "stepDelta": 0, + "elapsed": 9784, + "status": "abandoned", + "reason": "component-destroyed", + "metadata": { + "abandonedBy": "caller" + } + } + ], + "previousStep": "process", + "metadata": { + "abandonedBy": "caller" + }, + "application": "composition", + "reason": "component-destroyed" +} + +---------------------------------------------------------------------- +[12/28] SCENARIO route-navigation status=success delta=35ms +trigger: clicked the "Autocomplete" sidebar link (also destroys the file upload above) + +{ + "scenarioId": "23fa45b6-0970-47f2-a3f3-512ba39c8321", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/autocomplete", + "status": "success", + "startTime": "2026-09-05T11:11:10.999Z", + "endTime": "2026-09-05T11:11:11.034Z", + "delta": 35, + "elapsed": 9798, + "stepCount": 3, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 9763, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 1, + "endOffset": 4, + "stepDelta": 3, + "elapsed": 9767, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 4, + "endOffset": 35, + "stepDelta": 31, + "elapsed": 9798, + "status": "success" + }, + { + "name": "activate", + "startOffset": 35, + "metadata": { + "finalUrl": "/autocomplete/examples" + }, + "endOffset": 35, + "stepDelta": 0, + "elapsed": 9798, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 35, + "endOffset": 35, + "stepDelta": 0, + "elapsed": 9798, + "status": "success", + "metadata": { + "finalUrl": "/autocomplete/examples" + } + } + ], + "previousStep": "activate", + "metadata": { + "finalUrl": "/autocomplete/examples" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[13/28] SCENARIO autocomplete-search status=success delta=1004ms +trigger: typed into the async search autocomplete + +{ + "scenarioId": "022c9c68-c850-4032-8ba2-67bf47df0e5f", + "scenarioName": "autocomplete-search", + "feature": "autocomplete", + "operation": "single", + "status": "success", + "startTime": "2026-09-05T11:11:11.967Z", + "endTime": "2026-09-05T11:11:12.971Z", + "delta": 1004, + "elapsed": 11735, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 10731, + "status": "success" + }, + { + "name": "query", + "startOffset": 1, + "metadata": { + "resultCount": 4 + }, + "endOffset": 1005, + "stepDelta": 1004, + "elapsed": 11735, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 1004, + "endOffset": 1004, + "stepDelta": 0, + "elapsed": 11735, + "status": "success", + "metadata": { + "resultCount": 4 + } + } + ], + "previousStep": "query", + "metadata": { + "resultCount": 4 + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[14/28] SCENARIO autocomplete-validate status=success delta=3003ms +trigger: opened the async-validation autocomplete and selected an option + +{ + "scenarioId": "67b71fad-b792-4cbd-b81e-0128e9e54ff0", + "scenarioName": "autocomplete-validate", + "feature": "autocomplete", + "status": "success", + "startTime": "2026-09-05T11:11:13.434Z", + "endTime": "2026-09-05T11:11:16.437Z", + "delta": 3003, + "elapsed": 15200, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 12198, + "status": "success" + }, + { + "name": "validate", + "startOffset": 0, + "endOffset": 3003, + "stepDelta": 3003, + "elapsed": 15200, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 3003, + "endOffset": 3003, + "stepDelta": 0, + "elapsed": 15200, + "status": "success" + } + ], + "previousStep": "validate", + "application": "composition" +} + +---------------------------------------------------------------------- +[15/28] SCENARIO autocomplete-search status=success delta=1003ms +trigger: typed into the search autocomplete a second time (attempted supersede — landed as a clean success instead, see report notes) + +{ + "scenarioId": "8a645df4-ae00-4d52-b1e3-476dce551e82", + "scenarioName": "autocomplete-search", + "feature": "autocomplete", + "operation": "single", + "status": "success", + "startTime": "2026-09-05T11:11:17.879Z", + "endTime": "2026-09-05T11:11:18.882Z", + "delta": 1003, + "elapsed": 17647, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 16643, + "status": "success" + }, + { + "name": "query", + "startOffset": 1, + "metadata": { + "resultCount": 0 + }, + "endOffset": 1003, + "stepDelta": 1002, + "elapsed": 17647, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 1003, + "endOffset": 1003, + "stepDelta": 0, + "elapsed": 17647, + "status": "success", + "metadata": { + "resultCount": 0 + } + } + ], + "previousStep": "query", + "metadata": { + "resultCount": 0 + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[16/28] SCENARIO autocomplete-search status=abandoned delta=270ms +trigger: typed into the search autocomplete, then navigated away before its 1000ms search finished + +{ + "scenarioId": "04a86e4a-897a-4328-b649-3d6294f8122e", + "scenarioName": "autocomplete-search", + "feature": "autocomplete", + "operation": "single", + "status": "abandoned", + "startTime": "2026-09-05T11:11:19.844Z", + "endTime": "2026-09-05T11:11:20.114Z", + "delta": 270, + "elapsed": 18878, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 18608, + "status": "success" + }, + { + "name": "query", + "startOffset": 1, + "metadata": { + "abandonedBy": "caller" + }, + "endOffset": 270, + "stepDelta": 269, + "elapsed": 18878, + "status": "abandoned", + "reason": "component-destroyed" + }, + { + "name": "scenario-end", + "startOffset": 270, + "endOffset": 270, + "stepDelta": 0, + "elapsed": 18878, + "status": "abandoned", + "reason": "component-destroyed", + "metadata": { + "abandonedBy": "caller" + } + } + ], + "previousStep": "query", + "metadata": { + "abandonedBy": "caller" + }, + "application": "composition", + "reason": "component-destroyed" +} + +---------------------------------------------------------------------- +[17/28] SCENARIO route-navigation status=success delta=9ms +trigger: clicked the "Checkbox" sidebar link (also destroys the search above) + +{ + "scenarioId": "4a8d693a-89e5-4d52-b9c4-8cf4b372f964", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/checkbox", + "status": "success", + "startTime": "2026-09-05T11:11:20.107Z", + "endTime": "2026-09-05T11:11:20.116Z", + "delta": 9, + "elapsed": 18880, + "stepCount": 3, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 18871, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 1, + "endOffset": 3, + "stepDelta": 2, + "elapsed": 18874, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 3, + "endOffset": 9, + "stepDelta": 6, + "elapsed": 18880, + "status": "success" + }, + { + "name": "activate", + "startOffset": 9, + "metadata": { + "finalUrl": "/checkbox/examples" + }, + "endOffset": 9, + "stepDelta": 0, + "elapsed": 18880, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 9, + "endOffset": 9, + "stepDelta": 0, + "elapsed": 18880, + "status": "success", + "metadata": { + "finalUrl": "/checkbox/examples" + } + } + ], + "previousStep": "activate", + "metadata": { + "finalUrl": "/checkbox/examples" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[18/28] SCENARIO route-navigation status=success delta=477ms +trigger: clicked "Select" (best-effort attempt at a superseded navigation, paired with the click below) + +{ + "scenarioId": "b3396de1-2414-42da-83e8-8521b956bc09", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/select", + "status": "success", + "startTime": "2026-09-05T11:11:20.107Z", + "endTime": "2026-09-05T11:11:20.584Z", + "delta": 477, + "elapsed": 19348, + "stepCount": 3, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 19312, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 442, + "endOffset": 444, + "stepDelta": 2, + "elapsed": 19315, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 444, + "endOffset": 476, + "stepDelta": 32, + "elapsed": 19348, + "status": "success" + }, + { + "name": "activate", + "startOffset": 476, + "metadata": { + "finalUrl": "/select/examples" + }, + "endOffset": 477, + "stepDelta": 1, + "elapsed": 19348, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 477, + "endOffset": 477, + "stepDelta": 0, + "elapsed": 19348, + "status": "success", + "metadata": { + "finalUrl": "/select/examples" + } + } + ], + "previousStep": "activate", + "metadata": { + "finalUrl": "/select/examples" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[19/28] SCENARIO route-navigation status=success delta=82ms +trigger: immediately clicked "Table" after the click above — both landed as clean successes + +{ + "scenarioId": "97b6a03a-df1b-44fa-82d1-4ddcfd6b627c", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/table", + "status": "success", + "startTime": "2026-09-05T11:11:20.550Z", + "endTime": "2026-09-05T11:11:20.632Z", + "delta": 82, + "elapsed": 19396, + "stepCount": 3, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 19386, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 73, + "endOffset": 74, + "stepDelta": 1, + "elapsed": 19387, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 74, + "endOffset": 82, + "stepDelta": 8, + "elapsed": 19395, + "status": "success" + }, + { + "name": "activate", + "startOffset": 82, + "metadata": { + "finalUrl": "/table/examples" + }, + "endOffset": 82, + "stepDelta": 0, + "elapsed": 19396, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 82, + "endOffset": 82, + "stepDelta": 0, + "elapsed": 19396, + "status": "success", + "metadata": { + "finalUrl": "/table/examples" + } + } + ], + "previousStep": "activate", + "metadata": { + "finalUrl": "/table/examples" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[20/28] SCENARIO table-page-load status=abandoned delta=566ms +trigger: landing on the table page re-triggered its lazy load; navigated to "Checkbox" before it finished + +{ + "scenarioId": "daa75e72-d65f-4692-81e0-53968454f8b2", + "scenarioName": "table-page-load", + "feature": "table", + "status": "abandoned", + "startTime": "2026-09-05T11:11:20.812Z", + "endTime": "2026-09-05T11:11:21.378Z", + "delta": 566, + "elapsed": 20142, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 19576, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "metadata": { + "abandonedBy": "caller" + }, + "endOffset": 566, + "stepDelta": 566, + "elapsed": 20142, + "status": "abandoned", + "reason": "component-destroyed" + }, + { + "name": "scenario-end", + "startOffset": 566, + "endOffset": 566, + "stepDelta": 0, + "elapsed": 20142, + "status": "abandoned", + "reason": "component-destroyed", + "metadata": { + "abandonedBy": "caller" + } + } + ], + "previousStep": "fetch", + "metadata": { + "abandonedBy": "caller" + }, + "application": "composition", + "reason": "component-destroyed" +} + +---------------------------------------------------------------------- +[21/28] SCENARIO route-navigation status=success delta=759ms +trigger: clicked the "Checkbox" sidebar link (also destroys the table load above) + +{ + "scenarioId": "4790af22-87ae-4676-b081-886b2d9d3584", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/checkbox", + "status": "success", + "startTime": "2026-09-05T11:11:20.622Z", + "endTime": "2026-09-05T11:11:21.381Z", + "delta": 759, + "elapsed": 20144, + "stepCount": 3, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 20122, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 737, + "endOffset": 739, + "stepDelta": 2, + "elapsed": 20125, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 739, + "endOffset": 759, + "stepDelta": 20, + "elapsed": 20144, + "status": "success" + }, + { + "name": "activate", + "startOffset": 759, + "metadata": { + "finalUrl": "/checkbox/examples" + }, + "endOffset": 759, + "stepDelta": 0, + "elapsed": 20144, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 759, + "endOffset": 759, + "stepDelta": 0, + "elapsed": 20144, + "status": "success", + "metadata": { + "finalUrl": "/checkbox/examples" + } + } + ], + "previousStep": "activate", + "metadata": { + "finalUrl": "/checkbox/examples" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[22/28] BI theme_changed +trigger: clicked the dark-mode toggle + +{ + "eventName": "theme_changed", + "eventTime": "2026-09-05T11:11:21.816Z", + "metadata": { + "theme": "dark" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[23/28] BI theme_option_changed +trigger: opened appearance settings and picked the "Calm" color theme + +{ + "eventName": "theme_option_changed", + "eventTime": "2026-09-05T11:11:22.168Z", + "metadata": { + "dimension": "color", + "value": "calm" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[24/28] BI sidebar_searched +trigger: typed into the sidebar’s component search box + +{ + "eventName": "sidebar_searched", + "eventTime": "2026-09-05T11:11:22.919Z", + "metadata": { + "resultCount": 3 + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[25/28] BI code_copied +trigger: clicked "Copy" on a code block twice back-to-back (the second click was suppressed by the dedup window — no separate line for it) + +{ + "eventName": "code_copied", + "eventTime": "2026-09-05T11:11:23.326Z", + "metadata": { + "language": "html" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[26/28] BI code_copied +trigger: clicked "Copy" again, 500ms later — past the dedup window + +{ + "eventName": "code_copied", + "eventTime": "2026-09-05T11:11:23.942Z", + "metadata": { + "language": "html" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[27/28] BI sidebar_toggled +trigger: clicked the sidebar collapse button + +{ + "eventName": "sidebar_toggled", + "eventTime": "2026-09-05T11:11:24.144Z", + "metadata": { + "expanded": false + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[28/28] BI sidebar_toggled +trigger: clicked the sidebar collapse button again to reopen it + +{ + "eventName": "sidebar_toggled", + "eventTime": "2026-09-05T11:11:24.412Z", + "metadata": { + "expanded": true + }, + "application": "composition" +} + + +====================================================================== +-> Log API (CpsLoggerService) +====================================================================== + +---------------------------------------------------------------------- +[1/1] LOG Application started status=log +trigger: "Application started", logged once at boot + +{ + "timestamp": "2026-09-05T11:11:01.567Z", + "level": "log", + "message": "Application started", + "logger": "app", + "context": "AppTelemetry", + "metadata": { + "language": "en-US" + }, + "application": "composition", + "environment": "development", + "version": "22.0.0" +} + diff --git a/projects/cps-telemetry/telemetry-trace.txt b/projects/cps-telemetry/telemetry-trace.txt new file mode 100644 index 000000000..3fa9d8cc2 --- /dev/null +++ b/projects/cps-telemetry/telemetry-trace.txt @@ -0,0 +1,2051 @@ +Generated 2026-09-04T20:59:21.008Z by `npm run trace:telemetry` (telemetry-trace.capture.spec.ts). +34 entries recorded through the AWS RUM sink, 5 through CpsLoggerService. + +---------------------------------------------------------------------- +[1/34] SCENARIO route-navigation status=success delta=180ms +trigger: NavigationStart -> NavigationEnd settles as success + +{ + "scenarioId": "459be100-3ae7-4e35-ad4a-9c1e805b003f", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/table", + "status": "success", + "startTime": "2026-09-04T20:59:20.709Z", + "endTime": "2026-09-04T20:59:20.889Z", + "delta": 180, + "elapsed": 180, + "stepCount": 2, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 0, + "endOffset": 180, + "stepDelta": 180, + "elapsed": 180, + "status": "success" + }, + { + "name": "activate", + "startOffset": 180, + "metadata": { + "finalUrl": "/table" + }, + "endOffset": 180, + "stepDelta": 0, + "elapsed": 180, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 180, + "endOffset": 180, + "stepDelta": 0, + "elapsed": 180, + "status": "success", + "metadata": { + "finalUrl": "/table" + } + } + ], + "previousStep": "activate", + "metadata": { + "finalUrl": "/table" + }, + "application": "composition", + "sessionId": "audit-session" +} + +---------------------------------------------------------------------- +[2/34] SCENARIO route-navigation status=abandoned delta=60ms +trigger: NavigationStart -> NavigationCancel settles as abandoned + +{ + "scenarioId": "7e67624b-e177-4e83-ab0f-c264c0847236", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/file-upload", + "status": "abandoned", + "startTime": "2026-09-04T20:59:20.717Z", + "endTime": "2026-09-04T20:59:20.777Z", + "delta": 60, + "elapsed": 60, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 0, + "metadata": { + "abandonedBy": "caller" + }, + "endOffset": 60, + "stepDelta": 60, + "elapsed": 60, + "status": "abandoned", + "message": "guard rejected" + }, + { + "name": "scenario-end", + "startOffset": 60, + "endOffset": 60, + "stepDelta": 0, + "elapsed": 60, + "status": "abandoned", + "message": "guard rejected", + "metadata": { + "abandonedBy": "caller" + } + } + ], + "previousStep": "resolve-route", + "metadata": { + "abandonedBy": "caller" + }, + "application": "composition", + "sessionId": "audit-session", + "message": "guard rejected" +} + +---------------------------------------------------------------------- +[3/34] SCENARIO route-navigation status=abandoned delta=50ms +trigger: a second NavigationStart supersedes the first (abandoned) + +{ + "scenarioId": "118ca028-2208-485b-aefb-88916bd3f8c9", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/autocomplete", + "status": "abandoned", + "startTime": "2026-09-04T20:59:20.720Z", + "endTime": "2026-09-04T20:59:20.770Z", + "delta": 50, + "elapsed": 50, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 0, + "metadata": { + "abandonedBy": "caller" + }, + "endOffset": 50, + "stepDelta": 50, + "elapsed": 50, + "status": "abandoned", + "message": "superseded by navigation" + }, + { + "name": "scenario-end", + "startOffset": 50, + "endOffset": 50, + "stepDelta": 0, + "elapsed": 50, + "status": "abandoned", + "message": "superseded by navigation", + "metadata": { + "abandonedBy": "caller" + } + } + ], + "previousStep": "resolve-route", + "metadata": { + "abandonedBy": "caller" + }, + "application": "composition", + "sessionId": "audit-session", + "message": "superseded by navigation" +} + +---------------------------------------------------------------------- +[4/34] SCENARIO route-navigation status=success delta=160ms +trigger: a second NavigationStart supersedes the first (abandoned) + +{ + "scenarioId": "2ac41018-ae65-44dc-9770-dff7ac6adcac", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/autocomplete", + "status": "success", + "startTime": "2026-09-04T20:59:20.770Z", + "endTime": "2026-09-04T20:59:20.930Z", + "delta": 160, + "elapsed": 210, + "stepCount": 2, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 50, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 0, + "endOffset": 160, + "stepDelta": 160, + "elapsed": 210, + "status": "success" + }, + { + "name": "activate", + "startOffset": 160, + "metadata": { + "finalUrl": "/autocomplete" + }, + "endOffset": 160, + "stepDelta": 0, + "elapsed": 210, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 160, + "endOffset": 160, + "stepDelta": 0, + "elapsed": 210, + "status": "success", + "metadata": { + "finalUrl": "/autocomplete" + } + } + ], + "previousStep": "activate", + "metadata": { + "finalUrl": "/autocomplete" + }, + "application": "composition", + "sessionId": "audit-session" +} + +---------------------------------------------------------------------- +[5/34] SCENARIO route-navigation status=failure delta=220ms +trigger: NavigationStart -> NavigationError settles as failure, correlated to a log line + +{ + "scenarioId": "6b050bf3-bbb6-4993-a544-7c033702b841", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/broken-chunk", + "status": "failure", + "startTime": "2026-09-04T20:59:20.723Z", + "endTime": "2026-09-04T20:59:20.943Z", + "delta": 220, + "elapsed": 220, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 0, + "endOffset": 220, + "stepDelta": 220, + "elapsed": 220, + "status": "failure", + "error": { + "name": "Error", + "message": "ChunkLoadError: Loading chunk 12 failed." + } + }, + { + "name": "scenario-end", + "startOffset": 220, + "endOffset": 220, + "stepDelta": 0, + "elapsed": 220, + "status": "failure", + "error": { + "name": "Error", + "message": "ChunkLoadError: Loading chunk 12 failed." + } + } + ], + "previousStep": "resolve-route", + "application": "composition", + "sessionId": "audit-session", + "error": { + "name": "Error", + "message": "ChunkLoadError: Loading chunk 12 failed." + } +} + +---------------------------------------------------------------------- +[6/34] SCENARIO route-navigation status=timeout delta=30000ms +trigger: never settling times out after 30s + +{ + "scenarioId": "4dace8de-c3a7-4ebc-98b9-0adca6dac75e", + "scenarioName": "route-navigation", + "operation": "lazy-route-load", + "route": "/stuck-chunk", + "status": "timeout", + "startTime": "2026-09-04T20:59:20.730Z", + "endTime": "2026-09-04T20:59:50.730Z", + "delta": 30000, + "elapsed": 30000, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "success" + }, + { + "name": "resolve-route", + "startOffset": 0, + "endOffset": 30000, + "stepDelta": 30000, + "elapsed": 30000, + "status": "timeout", + "message": "Scenario did not settle within 30000ms" + }, + { + "name": "scenario-end", + "startOffset": 30000, + "endOffset": 30000, + "stepDelta": 0, + "elapsed": 30000, + "status": "timeout", + "message": "Scenario did not settle within 30000ms" + } + ], + "previousStep": "resolve-route", + "application": "composition", + "sessionId": "audit-session", + "message": "Scenario did not settle within 30000ms" +} + +---------------------------------------------------------------------- +[7/34] SCENARIO table-page-load status=success delta=600ms +trigger: lazy load settles as success, with the format-row aggregate + +{ + "scenarioId": "de1f3c48-c37e-4052-8ecd-79f9427ba077", + "scenarioName": "table-page-load", + "feature": "table", + "status": "success", + "startTime": "2026-09-04T20:59:20.736Z", + "endTime": "2026-09-04T20:59:21.336Z", + "delta": 600, + "elapsed": 600, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "metadata": { + "first": 0, + "rows": 10, + "totalRecords": 20 + }, + "endOffset": 600, + "stepDelta": 600, + "elapsed": 600, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 600, + "endOffset": 600, + "stepDelta": 0, + "elapsed": 600, + "status": "success", + "metadata": { + "first": 0, + "rows": 10, + "totalRecords": 20 + } + } + ], + "previousStep": "fetch", + "aggregates": [ + { + "name": "format-row", + "elapsed": 0, + "callCount": 10 + } + ], + "metadata": { + "first": 0, + "rows": 10, + "totalRecords": 20 + }, + "application": "composition", + "sessionId": "audit-session" +} + +---------------------------------------------------------------------- +[8/34] SCENARIO table-page-load status=abandoned delta=250ms +trigger: a second lazy load supersedes the first (abandoned) + +{ + "scenarioId": "81cf626b-520a-4cca-bded-36896c4b1903", + "scenarioName": "table-page-load", + "feature": "table", + "status": "abandoned", + "startTime": "2026-09-04T20:59:20.746Z", + "endTime": "2026-09-04T20:59:20.996Z", + "delta": 250, + "elapsed": 250, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "metadata": { + "abandonedBy": "caller" + }, + "endOffset": 250, + "stepDelta": 250, + "elapsed": 250, + "status": "abandoned", + "reason": "superseded" + }, + { + "name": "scenario-end", + "startOffset": 250, + "endOffset": 250, + "stepDelta": 0, + "elapsed": 250, + "status": "abandoned", + "reason": "superseded", + "metadata": { + "abandonedBy": "caller" + } + } + ], + "previousStep": "fetch", + "metadata": { + "abandonedBy": "caller" + }, + "application": "composition", + "sessionId": "audit-session", + "reason": "superseded" +} + +---------------------------------------------------------------------- +[9/34] SCENARIO table-page-load status=success delta=600ms +trigger: a second lazy load supersedes the first (abandoned) + +{ + "scenarioId": "c7faf133-3b3a-479d-aee4-0be57fc8f7a8", + "scenarioName": "table-page-load", + "feature": "table", + "status": "success", + "startTime": "2026-09-04T20:59:20.996Z", + "endTime": "2026-09-04T20:59:21.596Z", + "delta": 600, + "elapsed": 850, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 250, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "metadata": { + "first": 10, + "rows": 10, + "totalRecords": 20 + }, + "endOffset": 600, + "stepDelta": 600, + "elapsed": 850, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 600, + "endOffset": 600, + "stepDelta": 0, + "elapsed": 850, + "status": "success", + "metadata": { + "first": 10, + "rows": 10, + "totalRecords": 20 + } + } + ], + "previousStep": "fetch", + "aggregates": [ + { + "name": "format-row", + "elapsed": 0, + "callCount": 10 + } + ], + "metadata": { + "first": 10, + "rows": 10, + "totalRecords": 20 + }, + "application": "composition", + "sessionId": "audit-session" +} + +---------------------------------------------------------------------- +[10/34] SCENARIO table-page-load status=abandoned delta=300ms +trigger: component destroyed mid-flight settles as abandoned + +{ + "scenarioId": "1e3f49df-e871-47fb-a6b3-fac3b8f7628c", + "scenarioName": "table-page-load", + "feature": "table", + "status": "abandoned", + "startTime": "2026-09-04T20:59:20.848Z", + "endTime": "2026-09-04T20:59:21.148Z", + "delta": 300, + "elapsed": 300, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "metadata": { + "abandonedBy": "caller" + }, + "endOffset": 300, + "stepDelta": 300, + "elapsed": 300, + "status": "abandoned", + "reason": "component-destroyed" + }, + { + "name": "scenario-end", + "startOffset": 300, + "endOffset": 300, + "stepDelta": 0, + "elapsed": 300, + "status": "abandoned", + "reason": "component-destroyed", + "metadata": { + "abandonedBy": "caller" + } + } + ], + "previousStep": "fetch", + "metadata": { + "abandonedBy": "caller" + }, + "application": "composition", + "sessionId": "audit-session", + "reason": "component-destroyed" +} + +---------------------------------------------------------------------- +[11/34] SCENARIO file-upload status=success delta=3000ms +trigger: processExtraInfoUploadedFile settles as success + +{ + "scenarioId": "5d3c3bc4-d518-4c2a-8b06-0eaf7f6de3af", + "scenarioName": "file-upload", + "feature": "file-upload", + "operation": "process", + "status": "success", + "startTime": "2026-09-04T20:59:20.875Z", + "endTime": "2026-09-04T20:59:23.875Z", + "delta": 3000, + "elapsed": 3000, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "success" + }, + { + "name": "process", + "startOffset": 0, + "endOffset": 3000, + "stepDelta": 3000, + "elapsed": 3000, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 3000, + "endOffset": 3000, + "stepDelta": 0, + "elapsed": 3000, + "status": "success" + } + ], + "previousStep": "process", + "application": "composition", + "sessionId": "audit-session" +} + +---------------------------------------------------------------------- +[12/34] SCENARIO file-upload status=failure delta=500ms +trigger: processFailingUploadedFile settles as failure with no error object + +{ + "scenarioId": "a00e912d-cabf-4700-b8ac-9561c368a84a", + "scenarioName": "file-upload", + "feature": "file-upload", + "operation": "process-failing", + "status": "failure", + "startTime": "2026-09-04T20:59:20.915Z", + "endTime": "2026-09-04T20:59:21.415Z", + "delta": 500, + "elapsed": 500, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "success" + }, + { + "name": "process", + "startOffset": 0, + "endOffset": 500, + "stepDelta": 500, + "elapsed": 500, + "status": "failure" + }, + { + "name": "scenario-end", + "startOffset": 500, + "endOffset": 500, + "stepDelta": 0, + "elapsed": 500, + "status": "failure" + } + ], + "previousStep": "process", + "application": "composition", + "sessionId": "audit-session" +} + +---------------------------------------------------------------------- +[13/34] SCENARIO file-upload status=abandoned delta=1200ms +trigger: onExtraInfoFileProcessingCancelled settles as abandoned + +{ + "scenarioId": "88dcfa3e-2633-431b-a1d4-7a45d2fc0b19", + "scenarioName": "file-upload", + "feature": "file-upload", + "operation": "process", + "status": "abandoned", + "startTime": "2026-09-04T20:59:20.935Z", + "endTime": "2026-09-04T20:59:22.135Z", + "delta": 1200, + "elapsed": 1200, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "success" + }, + { + "name": "process", + "startOffset": 0, + "metadata": { + "abandonedBy": "caller" + }, + "endOffset": 1200, + "stepDelta": 1200, + "elapsed": 1200, + "status": "abandoned", + "reason": "user-cancelled" + }, + { + "name": "scenario-end", + "startOffset": 1200, + "endOffset": 1200, + "stepDelta": 0, + "elapsed": 1200, + "status": "abandoned", + "reason": "user-cancelled", + "metadata": { + "abandonedBy": "caller" + } + } + ], + "previousStep": "process", + "metadata": { + "abandonedBy": "caller" + }, + "application": "composition", + "sessionId": "audit-session", + "reason": "user-cancelled" +} + +---------------------------------------------------------------------- +[14/34] SCENARIO file-upload status=abandoned delta=800ms +trigger: ngOnDestroy mid-flight settles as abandoned + +{ + "scenarioId": "46e5d731-12f0-4ae0-9639-68ff29f48e7a", + "scenarioName": "file-upload", + "feature": "file-upload", + "operation": "process", + "status": "abandoned", + "startTime": "2026-09-04T20:59:20.937Z", + "endTime": "2026-09-04T20:59:21.737Z", + "delta": 800, + "elapsed": 800, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "success" + }, + { + "name": "process", + "startOffset": 0, + "metadata": { + "abandonedBy": "caller" + }, + "endOffset": 800, + "stepDelta": 800, + "elapsed": 800, + "status": "abandoned", + "reason": "component-destroyed" + }, + { + "name": "scenario-end", + "startOffset": 800, + "endOffset": 800, + "stepDelta": 0, + "elapsed": 800, + "status": "abandoned", + "reason": "component-destroyed", + "metadata": { + "abandonedBy": "caller" + } + } + ], + "previousStep": "process", + "metadata": { + "abandonedBy": "caller" + }, + "application": "composition", + "sessionId": "audit-session", + "reason": "component-destroyed" +} + +---------------------------------------------------------------------- +[15/34] SCENARIO file-upload status=failure delta=0ms +trigger: a rejecting file read logs "Error reading file" + +{ + "scenarioId": "87fdd6e4-0e7d-4b06-8984-693b9f83727b", + "scenarioName": "file-upload", + "feature": "file-upload", + "operation": "process", + "status": "failure", + "startTime": "2026-09-04T20:59:20.939Z", + "endTime": "2026-09-04T20:59:20.939Z", + "delta": 0, + "elapsed": 0, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "success" + }, + { + "name": "process", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "failure", + "error": { + "name": "Error", + "message": "Read permission denied by the OS", + "stack": "Error: Read permission denied by the OS\n at Object.text (/Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/telemetry-trace.capture.spec.ts:387:28)\n at FileUploadPageComponent._processUploadedFile (/Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.ts:89:22)\n at FileUploadPageComponent.processExtraInfoUploadedFile (/Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.ts:77:10)\n at /Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/telemetry-trace.capture.spec.ts:471:8\n at _ZoneDelegate.invoke (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone.umd.js:407:168)\n at _ProxyZoneSpec.onInvoke (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone-testing.umd.js:1079:43)\n at _ZoneDelegate.invoke (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone.umd.js:407:56)\n at _ZoneImpl.run (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone.umd.js:162:47)\n at Object.wrappedFunc (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone-testing.umd.js:407:38)\n at Promise.finally.completed (/Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:1561:28)\n at new Promise (<anonymous>)\n at callAsyncCircusFn (/Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:1501:10)\n at _callCircusTest (/Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:1011:40)\n at processTicksAndRejections (node:internal/process/task_queues:103:5)\n at _runTest (/Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:951:3)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:853:7" + } + }, + { + "name": "scenario-end", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "failure", + "error": { + "name": "Error", + "message": "Read permission denied by the OS", + "stack": "Error: Read permission denied by the OS\n at Object.text (/Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/telemetry-trace.capture.spec.ts:387:28)\n at FileUploadPageComponent._processUploadedFile (/Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.ts:89:22)\n at FileUploadPageComponent.processExtraInfoUploadedFile (/Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.ts:77:10)\n at /Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/telemetry-trace.capture.spec.ts:471:8\n at _ZoneDelegate.invoke (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone.umd.js:407:168)\n at _ProxyZoneSpec.onInvoke (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone-testing.umd.js:1079:43)\n at _ZoneDelegate.invoke (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone.umd.js:407:56)\n at _ZoneImpl.run (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone.umd.js:162:47)\n at Object.wrappedFunc (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone-testing.umd.js:407:38)\n at Promise.finally.completed (/Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:1561:28)\n at new Promise (<anonymous>)\n at callAsyncCircusFn (/Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:1501:10)\n at _callCircusTest (/Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:1011:40)\n at processTicksAndRejections (node:internal/process/task_queues:103:5)\n at _runTest (/Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:951:3)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:853:7" + } + } + ], + "previousStep": "process", + "application": "composition", + "sessionId": "audit-session", + "error": { + "name": "Error", + "message": "Read permission denied by the OS", + "stack": "Error: Read permission denied by the OS\n at Object.text (/Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/telemetry-trace.capture.spec.ts:387:28)\n at FileUploadPageComponent._processUploadedFile (/Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.ts:89:22)\n at FileUploadPageComponent.processExtraInfoUploadedFile (/Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.ts:77:10)\n at /Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/telemetry-trace.capture.spec.ts:471:8\n at _ZoneDelegate.invoke (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone.umd.js:407:168)\n at _ProxyZoneSpec.onInvoke (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone-testing.umd.js:1079:43)\n at _ZoneDelegate.invoke (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone.umd.js:407:56)\n at _ZoneImpl.run (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone.umd.js:162:47)\n at Object.wrappedFunc (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone-testing.umd.js:407:38)\n at Promise.finally.completed (/Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:1561:28)\n at new Promise (<anonymous>)\n at callAsyncCircusFn (/Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:1501:10)\n at _callCircusTest (/Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:1011:40)\n at processTicksAndRejections (node:internal/process/task_queues:103:5)\n at _runTest (/Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:951:3)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:853:7" + } +} + +---------------------------------------------------------------------- +[16/34] SCENARIO autocomplete-search status=success delta=1000ms +trigger: a search settles as success + +{ + "scenarioId": "7521f15b-aff6-488e-8e84-270ed51c7e68", + "scenarioName": "autocomplete-search", + "feature": "autocomplete", + "operation": "single", + "status": "success", + "startTime": "2026-09-04T20:59:20.954Z", + "endTime": "2026-09-04T20:59:21.954Z", + "delta": 1000, + "elapsed": 1000, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "success" + }, + { + "name": "query", + "startOffset": 0, + "metadata": { + "resultCount": 1 + }, + "endOffset": 1000, + "stepDelta": 1000, + "elapsed": 1000, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 1000, + "endOffset": 1000, + "stepDelta": 0, + "elapsed": 1000, + "status": "success", + "metadata": { + "resultCount": 1 + } + } + ], + "previousStep": "query", + "metadata": { + "resultCount": 1 + }, + "application": "composition", + "sessionId": "audit-session" +} + +---------------------------------------------------------------------- +[17/34] SCENARIO autocomplete-search status=failure delta=1000ms +trigger: a search settles as failure (backend stubbed down, same pattern the existing spec uses) + +{ + "scenarioId": "6ddfa627-199b-4cec-b650-dc93196490d8", + "scenarioName": "autocomplete-search", + "feature": "autocomplete", + "operation": "single", + "status": "failure", + "startTime": "2026-09-04T20:59:20.959Z", + "endTime": "2026-09-04T20:59:21.959Z", + "delta": 1000, + "elapsed": 1000, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "success" + }, + { + "name": "query", + "startOffset": 0, + "endOffset": 1000, + "stepDelta": 1000, + "elapsed": 1000, + "status": "failure", + "error": { + "name": "Error", + "message": "backend down", + "stack": "Error: backend down\n at /Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/telemetry-trace.capture.spec.ts:543:44\n at Observable.init [as _subscribe] (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/observable/throwError.ts:123:68)\n at Observable._trySubscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:235:19)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:225:18\n at Object.errorContext (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/util/errorContext.ts:29:5)\n at Observable.subscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:211:5)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/operators/switchMap.ts:108:49\n at OperatorSubscriber._this._next (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/operators/OperatorSubscriber.ts:70:13)\n at OperatorSubscriber.Subscriber.next (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Subscriber.ts:71:12)\n at AsyncAction.work (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/observable/timer.ts:173:20)\n at AsyncAction._execute (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/scheduler/AsyncAction.ts:119:12)\n at AsyncAction.execute (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/scheduler/AsyncAction.ts:94:24)\n at AsyncScheduler.flush (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/scheduler/AsyncScheduler.ts:38:27)\n at callTimer (/Users/madfamily/Documents/cps-shared-ui/node_modules/@sinonjs/fake-timers/src/fake-timers-src.js:1525:24)\n at runTimersInRange (/Users/madfamily/Documents/cps-shared-ui/node_modules/@sinonjs/fake-timers/src/fake-timers-src.js:2223:25)\n at doTickInner (/Users/madfamily/Documents/cps-shared-ui/node_modules/@sinonjs/fake-timers/src/fake-timers-s…" + } + }, + { + "name": "scenario-end", + "startOffset": 1000, + "endOffset": 1000, + "stepDelta": 0, + "elapsed": 1000, + "status": "failure", + "error": { + "name": "Error", + "message": "backend down", + "stack": "Error: backend down\n at /Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/telemetry-trace.capture.spec.ts:543:44\n at Observable.init [as _subscribe] (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/observable/throwError.ts:123:68)\n at Observable._trySubscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:235:19)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:225:18\n at Object.errorContext (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/util/errorContext.ts:29:5)\n at Observable.subscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:211:5)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/operators/switchMap.ts:108:49\n at OperatorSubscriber._this._next (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/operators/OperatorSubscriber.ts:70:13)\n at OperatorSubscriber.Subscriber.next (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Subscriber.ts:71:12)\n at AsyncAction.work (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/observable/timer.ts:173:20)\n at AsyncAction._execute (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/scheduler/AsyncAction.ts:119:12)\n at AsyncAction.execute (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/scheduler/AsyncAction.ts:94:24)\n at AsyncScheduler.flush (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/scheduler/AsyncScheduler.ts:38:27)\n at callTimer (/Users/madfamily/Documents/cps-shared-ui/node_modules/@sinonjs/fake-timers/src/fake-timers-src.js:1525:24)\n at runTimersInRange (/Users/madfamily/Documents/cps-shared-ui/node_modules/@sinonjs/fake-timers/src/fake-timers-src.js:2223:25)\n at doTickInner (/Users/madfamily/Documents/cps-shared-ui/node_modules/@sinonjs/fake-timers/src/fake-timers-s…" + } + } + ], + "previousStep": "query", + "application": "composition", + "sessionId": "audit-session", + "error": { + "name": "Error", + "message": "backend down", + "stack": "Error: backend down\n at /Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/telemetry-trace.capture.spec.ts:543:44\n at Observable.init [as _subscribe] (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/observable/throwError.ts:123:68)\n at Observable._trySubscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:235:19)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:225:18\n at Object.errorContext (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/util/errorContext.ts:29:5)\n at Observable.subscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:211:5)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/operators/switchMap.ts:108:49\n at OperatorSubscriber._this._next (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/operators/OperatorSubscriber.ts:70:13)\n at OperatorSubscriber.Subscriber.next (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Subscriber.ts:71:12)\n at AsyncAction.work (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/observable/timer.ts:173:20)\n at AsyncAction._execute (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/scheduler/AsyncAction.ts:119:12)\n at AsyncAction.execute (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/scheduler/AsyncAction.ts:94:24)\n at AsyncScheduler.flush (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/scheduler/AsyncScheduler.ts:38:27)\n at callTimer (/Users/madfamily/Documents/cps-shared-ui/node_modules/@sinonjs/fake-timers/src/fake-timers-src.js:1525:24)\n at runTimersInRange (/Users/madfamily/Documents/cps-shared-ui/node_modules/@sinonjs/fake-timers/src/fake-timers-src.js:2223:25)\n at doTickInner (/Users/madfamily/Documents/cps-shared-ui/node_modules/@sinonjs/fake-timers/src/fake-timers-s…" + } +} + +---------------------------------------------------------------------- +[18/34] SCENARIO autocomplete-search status=abandoned delta=220ms +trigger: a second keystroke supersedes the first search (abandoned) + +{ + "scenarioId": "17895192-fe43-4557-a0fa-2d674850e20d", + "scenarioName": "autocomplete-search", + "feature": "autocomplete", + "operation": "single", + "status": "abandoned", + "startTime": "2026-09-04T20:59:20.974Z", + "endTime": "2026-09-04T20:59:21.194Z", + "delta": 220, + "elapsed": 220, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "success" + }, + { + "name": "query", + "startOffset": 0, + "metadata": { + "abandonedBy": "caller" + }, + "endOffset": 220, + "stepDelta": 220, + "elapsed": 220, + "status": "abandoned", + "reason": "superseded" + }, + { + "name": "scenario-end", + "startOffset": 220, + "endOffset": 220, + "stepDelta": 0, + "elapsed": 220, + "status": "abandoned", + "reason": "superseded", + "metadata": { + "abandonedBy": "caller" + } + } + ], + "previousStep": "query", + "metadata": { + "abandonedBy": "caller" + }, + "application": "composition", + "sessionId": "audit-session", + "reason": "superseded" +} + +---------------------------------------------------------------------- +[19/34] SCENARIO autocomplete-search status=success delta=1000ms +trigger: a second keystroke supersedes the first search (abandoned) + +{ + "scenarioId": "b0a0be13-8e1f-4652-8f7c-60c442e56f46", + "scenarioName": "autocomplete-search", + "feature": "autocomplete", + "operation": "single", + "status": "success", + "startTime": "2026-09-04T20:59:21.194Z", + "endTime": "2026-09-04T20:59:22.194Z", + "delta": 1000, + "elapsed": 1220, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 220, + "status": "success" + }, + { + "name": "query", + "startOffset": 0, + "metadata": { + "resultCount": 1 + }, + "endOffset": 1000, + "stepDelta": 1000, + "elapsed": 1220, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 1000, + "endOffset": 1000, + "stepDelta": 0, + "elapsed": 1220, + "status": "success", + "metadata": { + "resultCount": 1 + } + } + ], + "previousStep": "query", + "metadata": { + "resultCount": 1 + }, + "application": "composition", + "sessionId": "audit-session" +} + +---------------------------------------------------------------------- +[20/34] SCENARIO autocomplete-validate status=success delta=3000ms +trigger: selecting an option settles validation as success + +{ + "scenarioId": "8e069b4e-6755-43bb-8c8d-b1052e5327bf", + "scenarioName": "autocomplete-validate", + "feature": "autocomplete", + "status": "success", + "startTime": "2026-09-04T20:59:20.977Z", + "endTime": "2026-09-04T20:59:23.977Z", + "delta": 3000, + "elapsed": 3000, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "success" + }, + { + "name": "validate", + "startOffset": 0, + "endOffset": 3000, + "stepDelta": 3000, + "elapsed": 3000, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 3000, + "endOffset": 3000, + "stepDelta": 0, + "elapsed": 3000, + "status": "success" + } + ], + "previousStep": "validate", + "application": "composition", + "sessionId": "audit-session" +} + +---------------------------------------------------------------------- +[21/34] SCENARIO autocomplete-search status=abandoned delta=400ms +trigger: destroying the component mid-flight settles every open scenario as abandoned + +{ + "scenarioId": "faa5eb71-9273-4989-8677-927ff1d8f5a6", + "scenarioName": "autocomplete-search", + "feature": "autocomplete", + "operation": "single", + "status": "abandoned", + "startTime": "2026-09-04T20:59:20.979Z", + "endTime": "2026-09-04T20:59:21.379Z", + "delta": 400, + "elapsed": 400, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "success" + }, + { + "name": "query", + "startOffset": 0, + "metadata": { + "abandonedBy": "caller" + }, + "endOffset": 400, + "stepDelta": 400, + "elapsed": 400, + "status": "abandoned", + "reason": "component-destroyed" + }, + { + "name": "scenario-end", + "startOffset": 400, + "endOffset": 400, + "stepDelta": 0, + "elapsed": 400, + "status": "abandoned", + "reason": "component-destroyed", + "metadata": { + "abandonedBy": "caller" + } + } + ], + "previousStep": "query", + "metadata": { + "abandonedBy": "caller" + }, + "application": "composition", + "sessionId": "audit-session", + "reason": "component-destroyed" +} + +---------------------------------------------------------------------- +[22/34] SCENARIO autocomplete-search status=abandoned delta=400ms +trigger: destroying the component mid-flight settles every open scenario as abandoned + +{ + "scenarioId": "2752480c-0615-4fb6-9ea7-23f83c95122d", + "scenarioName": "autocomplete-search", + "feature": "autocomplete", + "operation": "multi", + "status": "abandoned", + "startTime": "2026-09-04T20:59:20.979Z", + "endTime": "2026-09-04T20:59:21.379Z", + "delta": 400, + "elapsed": 400, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "success" + }, + { + "name": "query", + "startOffset": 0, + "metadata": { + "abandonedBy": "caller" + }, + "endOffset": 400, + "stepDelta": 400, + "elapsed": 400, + "status": "abandoned", + "reason": "component-destroyed" + }, + { + "name": "scenario-end", + "startOffset": 400, + "endOffset": 400, + "stepDelta": 0, + "elapsed": 400, + "status": "abandoned", + "reason": "component-destroyed", + "metadata": { + "abandonedBy": "caller" + } + } + ], + "previousStep": "query", + "metadata": { + "abandonedBy": "caller" + }, + "application": "composition", + "sessionId": "audit-session", + "reason": "component-destroyed" +} + +---------------------------------------------------------------------- +[23/34] SCENARIO autocomplete-validate status=abandoned delta=400ms +trigger: destroying the component mid-flight settles every open scenario as abandoned + +{ + "scenarioId": "705ec651-0c56-4c51-bc57-cbf4288e9f9d", + "scenarioName": "autocomplete-validate", + "feature": "autocomplete", + "status": "abandoned", + "startTime": "2026-09-04T20:59:20.979Z", + "endTime": "2026-09-04T20:59:21.379Z", + "delta": 400, + "elapsed": 400, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 0, + "status": "success" + }, + { + "name": "validate", + "startOffset": 0, + "metadata": { + "abandonedBy": "caller" + }, + "endOffset": 400, + "stepDelta": 400, + "elapsed": 400, + "status": "abandoned", + "reason": "component-destroyed" + }, + { + "name": "scenario-end", + "startOffset": 400, + "endOffset": 400, + "stepDelta": 0, + "elapsed": 400, + "status": "abandoned", + "reason": "component-destroyed", + "metadata": { + "abandonedBy": "caller" + } + } + ], + "previousStep": "validate", + "metadata": { + "abandonedBy": "caller" + }, + "application": "composition", + "sessionId": "audit-session", + "reason": "component-destroyed" +} + +---------------------------------------------------------------------- +[24/34] BI theme_changed +trigger: trackThemeChanged records theme_changed + +{ + "eventName": "theme_changed", + "eventTime": "2026-09-04T20:59:20.989Z", + "metadata": { + "theme": "dark" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[25/34] BI sidebar_toggled +trigger: trackClick records sidebar_toggled + +{ + "eventName": "sidebar_toggled", + "eventTime": "2026-09-04T20:59:20.992Z", + "metadata": { + "expanded": true + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[26/34] BI theme_option_changed +trigger: trackClick records theme_option_changed + +{ + "eventName": "theme_option_changed", + "eventTime": "2026-09-04T20:59:20.994Z", + "metadata": { + "dimension": "color", + "value": "blue" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[27/34] BI code_copied +trigger: the first click of the pair is recorded + +{ + "eventName": "code_copied", + "eventTime": "2026-09-04T20:59:20.995Z", + "metadata": { + "componentName": "cps-table" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[28/34] OBSERVATION {"firstCallRecorded":true} +trigger: an identical click within the dedup window is suppressed + +{ + "firstCallRecorded": true +} + +---------------------------------------------------------------------- +[29/34] BI code_copied +trigger: the same click after the dedup window elapses is recorded again + +{ + "eventName": "code_copied", + "eventTime": "2026-09-04T20:59:20.997Z", + "metadata": { + "componentName": "cps-select" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[30/34] BI code_copied +trigger: the same click after the dedup window elapses is recorded again + +{ + "eventName": "code_copied", + "eventTime": "2026-09-04T20:59:21.398Z", + "metadata": { + "componentName": "cps-select" + }, + "application": "composition" +} + +---------------------------------------------------------------------- +[31/34] SCENARIO table-page-load status=incomplete delta=0ms +trigger: `incomplete` — no composition page ever calls .incomplete() + +{ + "scenarioId": "d9dabd59-e830-4042-9444-b1cc807c8cad", + "scenarioName": "table-page-load", + "feature": "table", + "status": "incomplete", + "startTime": "2026-09-04T20:59:20.998Z", + "endTime": "2026-09-04T20:59:20.998Z", + "delta": 0, + "elapsed": 2831, + "stepCount": 0, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2831, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2831, + "status": "incomplete", + "reason": "no-results" + } + ], + "application": "composition", + "sessionId": "audit-session", + "reason": "no-results" +} + +---------------------------------------------------------------------- +[32/34] SCENARIO table-page-load status=success delta=0ms +trigger: `exceededStepsLimit` — no composition scenario ever opens 50+ steps + +{ + "scenarioId": "90a53fdb-ac9f-46eb-a0af-53996d86a308", + "scenarioName": "table-page-load", + "feature": "table", + "status": "success", + "startTime": "2026-09-04T20:59:20.999Z", + "endTime": "2026-09-04T20:59:20.999Z", + "delta": 0, + "elapsed": 2833, + "stepCount": 55, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2832, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2833, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2833, + "status": "success" + }, + { + "name": "fetch", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2833, + "status": "success" + }, + { + "name": "scenario-end", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2833, + "status": "success" + } + ], + "previousStep": "fetch", + "application": "composition", + "sessionId": "audit-session", + "exceededStepsLimit": true +} + +---------------------------------------------------------------------- +[33/34] SCENARIO autocomplete-validate status=failure delta=0ms +trigger: autocomplete-validate failure — onOptionSelected has no stubbable network seam + +{ + "scenarioId": "f344d9fa-d90d-472d-81b3-6d0fc47865b0", + "scenarioName": "autocomplete-validate", + "feature": "autocomplete", + "status": "failure", + "startTime": "2026-09-04T20:59:21.001Z", + "endTime": "2026-09-04T20:59:21.001Z", + "delta": 0, + "elapsed": 2835, + "stepCount": 1, + "steps": [ + { + "name": "scenario-start", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2834, + "status": "success" + }, + { + "name": "validate", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2834, + "status": "failure", + "error": { + "name": "Error", + "message": "Selected option failed remote validation", + "stack": "Error: Selected option failed remote validation\n at /Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/telemetry-trace.capture.spec.ts:729:22\n at Observable.init [as _subscribe] (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/observable/throwError.ts:123:68)\n at Observable._trySubscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:235:19)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:225:18\n at Object.errorContext (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/util/errorContext.ts:29:5)\n at Observable.subscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:211:5)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/operators/tap.ts:185:16\n at SafeSubscriber.<anonymous> (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/util/lift.ts:24:18)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:217:22\n at Object.errorContext (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/util/errorContext.ts:29:5)\n at Observable.subscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:211:5)\n at Observable._subscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/observable/defer.ts:54:36)\n at Observable._trySubscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:235:19)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:225:18\n at Object.errorContext (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/util/errorContext.ts:29:5)\n at Observable.subscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:211:5)\n at /Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/te…" + } + }, + { + "name": "scenario-end", + "startOffset": 0, + "endOffset": 0, + "stepDelta": 0, + "elapsed": 2835, + "status": "failure", + "error": { + "name": "Error", + "message": "Selected option failed remote validation", + "stack": "Error: Selected option failed remote validation\n at /Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/telemetry-trace.capture.spec.ts:729:22\n at Observable.init [as _subscribe] (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/observable/throwError.ts:123:68)\n at Observable._trySubscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:235:19)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:225:18\n at Object.errorContext (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/util/errorContext.ts:29:5)\n at Observable.subscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:211:5)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/operators/tap.ts:185:16\n at SafeSubscriber.<anonymous> (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/util/lift.ts:24:18)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:217:22\n at Object.errorContext (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/util/errorContext.ts:29:5)\n at Observable.subscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:211:5)\n at Observable._subscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/observable/defer.ts:54:36)\n at Observable._trySubscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:235:19)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:225:18\n at Object.errorContext (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/util/errorContext.ts:29:5)\n at Observable.subscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:211:5)\n at /Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/te…" + } + } + ], + "previousStep": "validate", + "application": "composition", + "sessionId": "audit-session", + "error": { + "name": "Error", + "message": "Selected option failed remote validation", + "stack": "Error: Selected option failed remote validation\n at /Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/telemetry-trace.capture.spec.ts:729:22\n at Observable.init [as _subscribe] (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/observable/throwError.ts:123:68)\n at Observable._trySubscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:235:19)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:225:18\n at Object.errorContext (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/util/errorContext.ts:29:5)\n at Observable.subscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:211:5)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/operators/tap.ts:185:16\n at SafeSubscriber.<anonymous> (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/util/lift.ts:24:18)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:217:22\n at Object.errorContext (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/util/errorContext.ts:29:5)\n at Observable.subscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:211:5)\n at Observable._subscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/observable/defer.ts:54:36)\n at Observable._trySubscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:235:19)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:225:18\n at Object.errorContext (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/util/errorContext.ts:29:5)\n at Observable.subscribe (/Users/madfamily/Documents/cps-shared-ui/node_modules/rxjs/src/internal/Observable.ts:211:5)\n at /Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/te…" + } +} + +---------------------------------------------------------------------- +[34/34] ERROR recordError(Error: Simulated unexpected failure) +trigger: an error-level log is also mirrored to AWS RUM when the config is turned on + +{ + "name": "Error", + "message": "Simulated unexpected failure", + "stack": "Error: Simulated unexpected failure\n at /Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/telemetry-trace.capture.spec.ts:762:14\n at _ZoneDelegate.invoke (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone.umd.js:407:168)\n at _ProxyZoneSpec.onInvoke (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone-testing.umd.js:1079:43)\n at _ZoneDelegate.invoke (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone.umd.js:407:56)\n at _ZoneImpl.run (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone.umd.js:162:47)\n at Object.wrappedFunc (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone-testing.umd.js:407:38)\n at Promise.finally.completed (/Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:1561:28)\n at new Promise (<anonymous>)\n at callAsyncCircusFn (/Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:1501:10)\n at _callCircusTest (/Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:1011:40)\n at processTicksAndRejections (node:internal/process/task_queues:103:5)\n at _runTest (/Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:951:3)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:853:7" +} + + +====================================================================== +-> Log API (CpsLoggerService) +====================================================================== +LOG entries are the literal CpsLogRecord handed to CpsLogBatcher; the +OBSERVATION entry is a synthetic assertion about redaction/minLevel +behavior rather than a payload the library itself emits. + +---------------------------------------------------------------------- +[1/5] LOG Application started status=log +trigger: logs "Application started" at boot + +{ + "timestamp": "2026-09-04T20:59:19.569Z", + "level": "log", + "message": "Application started", + "logger": "app", + "context": "AppTelemetry", + "metadata": { + "language": "en-US" + }, + "application": "composition", + "environment": "production", + "version": "1.0.0", + "sessionId": "audit-session" +} + +---------------------------------------------------------------------- +[2/5] LOG Navigation failed status=error +trigger: NavigationStart -> NavigationError settles as failure, correlated to a log line + +{ + "timestamp": "2026-09-04T20:59:20.943Z", + "level": "error", + "message": "Navigation failed", + "logger": "app", + "context": "route-navigation", + "metadata": { + "url": "/broken-chunk" + }, + "error": { + "name": "Error", + "message": "ChunkLoadError: Loading chunk 12 failed." + }, + "correlationId": "6b050bf3-bbb6-4993-a544-7c033702b841", + "application": "composition", + "environment": "production", + "version": "1.0.0", + "sessionId": "audit-session" +} + +---------------------------------------------------------------------- +[3/5] LOG Error reading file status=error +trigger: a rejecting file read logs "Error reading file" + +{ + "timestamp": "2026-09-04T20:59:20.939Z", + "level": "error", + "message": "Error reading file", + "logger": "file-upload", + "context": "FileUpload", + "error": { + "name": "Error", + "message": "Read permission denied by the OS", + "stack": "Error: Read permission denied by the OS\n at Object.text (/Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/telemetry-trace.capture.spec.ts:387:28)\n at FileUploadPageComponent._processUploadedFile (/Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.ts:89:22)\n at FileUploadPageComponent.processExtraInfoUploadedFile (/Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/pages/file-upload-page/file-upload-page.component.ts:77:10)\n at /Users/madfamily/Documents/cps-shared-ui/projects/composition/src/app/telemetry-trace.capture.spec.ts:471:8\n at _ZoneDelegate.invoke (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone.umd.js:407:168)\n at _ProxyZoneSpec.onInvoke (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone-testing.umd.js:1079:43)\n at _ZoneDelegate.invoke (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone.umd.js:407:56)\n at _ZoneImpl.run (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone.umd.js:162:47)\n at Object.wrappedFunc (/Users/madfamily/Documents/cps-shared-ui/node_modules/zone.js/bundles/zone-testing.umd.js:407:38)\n at Promise.finally.completed (/Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:1561:28)\n at new Promise (<anonymous>)\n at callAsyncCircusFn (/Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:1501:10)\n at _callCircusTest (/Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:1011:40)\n at processTicksAndRejections (node:internal/process/task_queues:103:5)\n at _runTest (/Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:951:3)\n at /Users/madfamily/Documents/cps-shared-ui/node_modules/jest-circus/build/jestAdapterInit.js:853:7" + }, + "correlationId": "87fdd6e4-0e7d-4b06-8984-693b9f83727b", + "application": "composition", + "environment": "production", + "version": "1.0.0", + "sessionId": "audit-session" +} + +---------------------------------------------------------------------- +[4/5] LOG User submitted contact form status=log +trigger: the key denylist and value-pattern scan both redact, when scanValuePatterns is turned on + +{ + "timestamp": "2026-09-04T20:59:21.005Z", + "level": "log", + "message": "User submitted contact form", + "logger": "app", + "metadata": { + "email": "[redacted]", + "password": "[redacted]", + "notes": "Card ending in [redacted], please call back." + }, + "application": "composition", + "environment": "production", + "version": "1.0.0", + "sessionId": "audit-session" +} + +---------------------------------------------------------------------- +[5/5] OBSERVATION {"droppedCallRecorded":false,"warnCallRecorded":true} +trigger: a log-level call is dropped once minLevel is raised to "warn" + +{ + "droppedCallRecorded": false, + "warnCallRecorded": true +} + diff --git a/projects/cps-telemetry/tsconfig.lib.json b/projects/cps-telemetry/tsconfig.lib.json new file mode 100644 index 000000000..693cd53ff --- /dev/null +++ b/projects/cps-telemetry/tsconfig.lib.json @@ -0,0 +1,33 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/lib", + "rootDir": "./src", + "lib": ["ES2022", "DOM"], + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "skipLibCheck": true, + "types": [], + // The package build resolves cps-ui-kit (used only by the + // cps-telemetry/diagnostics entry) as the external package it is at + // runtime, from its built output — never compiling the kit's source into + // this package. Build cps-ui-kit first. ng-packagr maps cps-telemetry's + // own entry points itself. + "paths": { + "cps-ui-kit": ["../../dist/cps-ui-kit"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts"], + "angularCompilerOptions": { + "extendedDiagnostics": { + "checks": { + "nullishCoalescingNotNullable": "suppress", + "optionalChainNotNullable": "suppress" + } + } + } +} diff --git a/projects/cps-telemetry/tsconfig.lib.prod.json b/projects/cps-telemetry/tsconfig.lib.prod.json new file mode 100644 index 000000000..9fb436e8a --- /dev/null +++ b/projects/cps-telemetry/tsconfig.lib.prod.json @@ -0,0 +1,16 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial", + "extendedDiagnostics": { + "checks": { + "nullishCoalescingNotNullable": "suppress", + "optionalChainNotNullable": "suppress" + } + } + } +} diff --git a/projects/cps-telemetry/tsconfig.spec.json b/projects/cps-telemetry/tsconfig.spec.json new file mode 100644 index 000000000..16f49dcef --- /dev/null +++ b/projects/cps-telemetry/tsconfig.spec.json @@ -0,0 +1,11 @@ +/* To learn more about this file see: https://angular.io/config/tsconfig. */ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/spec", + "rootDir": "./src", + "types": ["jest"], + "resolveJsonModule": true + }, + "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts index cc01c32ad..2ba70d0d5 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-autocomplete/cps-autocomplete.component.spec.ts @@ -1454,7 +1454,7 @@ describe('CpsAutocompleteComponent', () => { const idAtOriginalIndex = component.getOptionId(option, 0); const idWithDifferentIndexArg = component.getOptionId(option, 5); expect(idWithDifferentIndexArg).toBe(idAtOriginalIndex); - expect(idWithDifferentIndexArg).not.toContain('-5'); + expect(idWithDifferentIndexArg).not.toMatch(/-5$/); }); }); diff --git a/tsconfig.json b/tsconfig.json index f8c74ca9c..56d87a2a2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,14 @@ "compileOnSave": false, "compilerOptions": { "resolveJsonModule": true, - "paths": { "cps-ui-kit": ["./projects/cps-ui-kit/src/public-api.ts"] }, + "paths": { + "cps-ui-kit": ["./projects/cps-ui-kit/src/public-api.ts"], + "cps-telemetry": ["./projects/cps-telemetry/src/public-api.ts"], + "cps-telemetry/rum": ["./projects/cps-telemetry/rum/src/public-api.ts"], + "cps-telemetry/diagnostics": [ + "./projects/cps-telemetry/diagnostics/src/public-api.ts" + ] + }, "esModuleInterop": true, "outDir": "./dist/out-tsc", "forceConsistentCasingInFileNames": true, @@ -33,6 +40,12 @@ "references": [ { "path": "./projects/cps-ui-kit/tsconfig.lib.json" }, { "path": "./projects/cps-ui-kit/tsconfig.spec.json" }, + { "path": "./projects/cps-telemetry/tsconfig.lib.json" }, + { "path": "./projects/cps-telemetry/tsconfig.spec.json" }, + { "path": "./projects/cps-telemetry/rum/tsconfig.lib.json" }, + { "path": "./projects/cps-telemetry/rum/tsconfig.spec.json" }, + { "path": "./projects/cps-telemetry/diagnostics/tsconfig.lib.json" }, + { "path": "./projects/cps-telemetry/diagnostics/tsconfig.spec.json" }, { "path": "./projects/composition/tsconfig.app.json" }, { "path": "./projects/composition/tsconfig.spec.json" } ],