From 0d8907e8489d29bdd0d0ba4bdac6fa14bc28245a Mon Sep 17 00:00:00 2001 From: fateeand Date: Wed, 11 Nov 2026 13:56:46 +0100 Subject: [PATCH 1/3] test: add API, type and wire-protocol safety nets --- .github/workflows/checks.yml | 3 + .gitignore | 3 + README.md | 39 +- package.json | 8 +- .../public-api.snapshot.json | 572 ++++++++++++++++++ .../uwt-broadcast.wire.fixture.json | 254 ++++++++ .../uwt-broadcast/uwt-broadcast.wire.spec.ts | 327 ++++++++++ projects/ngx-ui-watchtower/tsconfig.spec.json | 2 +- .../ngx-ui-watchtower/type-tests/augmented.ts | 138 +++++ .../ngx-ui-watchtower/type-tests/compat.ts | 342 +++++++++++ .../type-tests/empty-registry.ts | 28 + .../type-tests/tsconfig.augmented.json | 5 + .../type-tests/tsconfig.json | 14 + scripts/api-snapshot.mjs | 226 +++++++ 14 files changed, 1947 insertions(+), 14 deletions(-) create mode 100644 projects/ngx-ui-watchtower/public-api.snapshot.json create mode 100644 projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json create mode 100644 projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts create mode 100644 projects/ngx-ui-watchtower/type-tests/augmented.ts create mode 100644 projects/ngx-ui-watchtower/type-tests/compat.ts create mode 100644 projects/ngx-ui-watchtower/type-tests/empty-registry.ts create mode 100644 projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json create mode 100644 projects/ngx-ui-watchtower/type-tests/tsconfig.json create mode 100644 scripts/api-snapshot.mjs diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index e74f21c..98f94c7 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -36,6 +36,9 @@ jobs: - name: Build library run: npm run build + - name: Check the public API + run: npm run api:check + test: name: Unit tests runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index dd2bf6e..925e170 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ testem.log # System files .DS_Store Thumbs.db + +# API snapshot output on a mismatch +*.snapshot.json.actual diff --git a/README.md b/README.md index a17a61b..f8f6293 100644 --- a/README.md +++ b/README.md @@ -41,16 +41,34 @@ The package is built to `dist/ngx-ui-watchtower`. ## Scripts -| Script | What it does | -| ----------------------- | ---------------------------------------------------- | -| `npm run build` | Builds the package (production, partial compilation) | -| `npm run watch` | Rebuilds on change (development) | -| `npm run test` | Runs the unit tests (Jest) | -| `npm run test:coverage` | Runs the unit tests with coverage, as CI does | -| `npm run typecheck` | Type-checks every library and spec tsconfig | -| `npm run lint` | ESLint, no warnings allowed | -| `npm run format` | Formats with Prettier | -| `npm run format:check` | Checks formatting without writing | +| Script | What it does | +| ----------------------- | --------------------------------------------------------------- | +| `npm run build` | Builds the package (production, partial compilation) | +| `npm run watch` | Rebuilds on change (development) | +| `npm run test` | Runs the unit tests (Jest) | +| `npm run test:coverage` | Runs the unit tests with coverage, as CI does | +| `npm run typecheck` | Type-checks every library and spec tsconfig, and the type tests | +| `npm run api:check` | Compares the built public API with its committed snapshot | +| `npm run api:update` | Writes that snapshot again, for an intended API change | +| `npm run lint` | ESLint, no warnings allowed | +| `npm run format` | Formats with Prettier | +| `npm run format:check` | Checks formatting without writing | + +Three checks guard what consumers depend on: + +- **Type tests** (`projects/ngx-ui-watchtower/type-tests/`) compile, with the + Angular compiler, a consumer that uses every public export, and one that + augments the name registries: registered names accepted, others rejected, + `string` while nothing is registered. +- **The API snapshot** (`projects/ngx-ui-watchtower/public-api.snapshot.json`) + records every export of the built package with its members and + signatures. After an intended change, run `npm run api:update` and review + the diff. +- **The broadcast wire fixture** + (`projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json`) records + every message a shell and a fragment exchange. Shells and fragments are + deployed separately, so it changes only with a deliberate protocol change: + `UPDATE_WIRE_FIXTURE=1 npm test` records it again. ## Before you push @@ -62,6 +80,7 @@ npm run typecheck npm run lint npm run format:check npm run build +npm run api:check ``` CI runs the same checks on every pull request and every push to `main`. diff --git a/package.json b/package.json index 0c44ab7..076ed36 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,11 @@ "test": "ng test --project=ngx-ui-watchtower", "test:coverage": "ng test --project=ngx-ui-watchtower --coverage --coverage-reporters text-summary --coverage-reporters lcov --json --output-file=./coverage/report.json", "lint": "eslint \"**/*.ts\" --max-warnings=0", - "format": "prettier --write \"**/*.{ts,html}\"", - "format:check": "prettier --check \"**/*.{ts,html}\"", - "typecheck": "tsc -p projects/ngx-ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.spec.json --noEmit" + "format": "prettier --write \"**/*.{ts,mjs,html}\"", + "format:check": "prettier --check \"**/*.{ts,mjs,html}\"", + "typecheck": "tsc -p projects/ngx-ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.spec.json --noEmit && ngc -p projects/ngx-ui-watchtower/type-tests/tsconfig.json && ngc -p projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json", + "api:check": "node scripts/api-snapshot.mjs", + "api:update": "node scripts/api-snapshot.mjs --update" }, "private": true, "dependencies": { diff --git a/projects/ngx-ui-watchtower/public-api.snapshot.json b/projects/ngx-ui-watchtower/public-api.snapshot.json new file mode 100644 index 0000000..b18d4f0 --- /dev/null +++ b/projects/ngx-ui-watchtower/public-api.snapshot.json @@ -0,0 +1,572 @@ +{ + "@absaoss-cps/ngx-ui-watchtower": { + "provideUwtTelemetry": [ + "function(identity: UwtTelemetryIdentity, ...features: UwtTelemetryFeature[]): EnvironmentProviders" + ], + "provideUwtTelemetryBroadcastHost": [ + "function(channelName?: string | undefined): EnvironmentProviders" + ], + "provideUwtTelemetryDestination": [ + "function(sink: Type, options?: UwtTelemetryDestinationOptions | undefined): EnvironmentProviders" + ], + "provideUwtTelemetrySink": [ + "function(mode: UwtTelemetryLocalSinkMode, options?: { channelName?: string | undefined; } | undefined): EnvironmentProviders" + ], + "traceScenario": [ + "function(scenario: UwtScenario, options?: UwtTraceScenarioOptions | ((value: T) => void | UwtScenarioOutcome) | undefined): MonoTypeOperatorFunction" + ], + "UWT_BI_TELEMETRY_CONFIG": [ + "const: InjectionToken" + ], + "UWT_BROADCAST_CHANNEL": [ + "const: InjectionToken" + ], + "UWT_DEFAULT_BROADCAST_CHANNEL": [ + "const: 'ngx-ui-watchtower'" + ], + "UWT_DEFAULT_EVENT_NAMESPACE": [ + "const: 'com.uwt'" + ], + "UWT_DEFAULT_REDACT_CONFIG": [ + "const: UwtRedactConfig" + ], + "UWT_DEFAULT_TELEMETRY_CONFIG": [ + "const: { eventNamespace: string; scenario: UwtScenarioTelemetryConfig; logs: UwtLogConfig; bi: UwtBITelemetryConfig; redact: UwtRedactConfig; }" + ], + "UWT_LOG_API_PROVIDER": [ + "const: InjectionToken" + ], + "UWT_LOG_CONFIG": [ + "const: InjectionToken" + ], + "UWT_LOG_LEVEL_ORDER": [ + "const: Record" + ], + "UWT_REDACT_CONFIG": [ + "const: InjectionToken" + ], + "UWT_REDACTED": [ + "const: '[redacted]'" + ], + "UWT_SCENARIO_TELEMETRY_CONFIG": [ + "const: InjectionToken" + ], + "UWT_TELEMETRY_ENABLED": [ + "const: InjectionToken" + ], + "UWT_TELEMETRY_EVENT_TYPE": [ + "const: UwtTelemetryEventTypes" + ], + "UWT_TELEMETRY_IDENTITY": [ + "const: InjectionToken" + ], + "UwtBIEvent": [ + "interface", + "application: string", + "eventName: string", + "eventTime: string", + "feature?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "scenarioId?: string | undefined", + "sessionId?: string | undefined", + "userId?: string | undefined" + ], + "UwtBIEventDetail": [ + "interface", + "feature?: string | undefined", + "scenarioId?: string | undefined" + ], + "UwtBIEventName": [ + "type = string" + ], + "UwtBIEventNames": [ + "interface" + ], + "UwtBITelemetryConfig": [ + "interface", + "dedupMaxKeys: number", + "dedupWindowMs: number", + "redact: boolean" + ], + "UwtBITelemetryService": [ + "class", + "new (): UwtBITelemetryService", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "track(eventName: string, metadata?: UwtTelemetryMetadata | undefined, detail?: UwtBIEventDetail | undefined): void" + ], + "UwtBroadcastLogApiProvider": [ + "class", + "new (): UwtBroadcastLogApiProvider", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "flush(): void", + "ngOnDestroy(): void", + "query(filter: UwtLogQuery): Promise", + "send(record: UwtLogRecord): void" + ], + "UwtBroadcastTelemetrySink": [ + "class", + "new (): UwtBroadcastTelemetrySink", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "flush(beacon?: boolean | undefined): void", + "getSessionId(): string | undefined", + "getUserId(): string | undefined", + "ngOnDestroy(): void", + "record(eventType: string, payload: object, metadata?: UwtTelemetryMetadata | undefined): void", + "recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata | undefined): void", + "setUserId(userId: string | undefined): void" + ], + "uwtClassifyTelemetryEvent": [ + "function(eventType: string, payload: object): UwtTelemetrySinkEvent" + ], + "UwtDebugFlag": [ + "type = 'debugLogger' | 'debugScenario' | 'debugBI'" + ], + "uwtEventTypes": [ + "function(namespace?: string | undefined): UwtTelemetryEventTypes" + ], + "uwtIsDebugEnabled": [ + "function(flag: UwtDebugFlag, name?: string | undefined): boolean" + ], + "UwtLogApiProvider": [ + "interface", + "flush?(): void", + "query(filter: UwtLogQuery): Promise", + "send(record: UwtLogRecord): void" + ], + "UwtLogConfig": [ + "interface", + "levels?: Partial> | undefined", + "minLevel: UwtLogLevel", + "mirrorErrorsToRum: boolean", + "redact: boolean" + ], + "UwtLogDetail": [ + "interface", + "context?: string | undefined", + "correlationId?: string | undefined", + "error?: unknown", + "metadata?: UwtTelemetryMetadata | undefined" + ], + "UwtLogger": [ + "interface", + "error(message: string, detail?: UwtLogDetail | undefined): void", + "log(message: string, detail?: UwtLogDetail | undefined): void", + "warn(message: string, detail?: UwtLogDetail | undefined): void" + ], + "UwtLoggerName": [ + "type = string" + ], + "UwtLoggerNames": [ + "interface" + ], + "UwtLoggerService": [ + "class", + "new (): UwtLoggerService", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "getLogger(name: string): UwtLogger", + "ngOnDestroy(): void", + "query(filter?: UwtLogQuery | undefined): Promise" + ], + "UwtLogLevel": [ + "type = 'log' | 'warn' | 'error'" + ], + "UwtLogQuery": [ + "interface", + "correlationId?: string | undefined", + "from?: string | undefined", + "limit?: number | undefined", + "logger?: string | undefined", + "minLevel?: UwtLogLevel | undefined", + "to?: string | undefined" + ], + "UwtLogRecord": [ + "interface", + "application: string", + "context?: string | undefined", + "correlationId?: string | undefined", + "environment: string", + "error?: UwtTelemetryError | undefined", + "level: UwtLogLevel", + "logger?: string | undefined", + "message: string", + "metadata?: UwtTelemetryMetadata | undefined", + "sessionId?: string | undefined", + "timestamp: string", + "userId?: string | undefined", + "version: string" + ], + "uwtMarkSessionStart": [ + "function(sessionId: string, now?: number | undefined): void" + ], + "UwtNoopLogApiProvider": [ + "class", + "new (): UwtNoopLogApiProvider", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "query(_filter: UwtLogQuery): Promise", + "send(_record: UwtLogRecord): void" + ], + "UwtNoopTelemetrySink": [ + "class", + "new (): UwtNoopTelemetrySink", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "flush(_beacon?: boolean | undefined): void", + "getSessionId(): string | undefined", + "getUserId(): string | undefined", + "record(_eventType: string, _payload: object, _metadata?: UwtTelemetryMetadata | undefined): void", + "recordError(_error: UwtTelemetryError, _metadata?: UwtTelemetryMetadata | undefined): void", + "setUserId(userId: string | undefined): void" + ], + "uwtNormalizeError": [ + "function(error: unknown, config?: UwtRedactConfig | undefined): UwtTelemetryError | undefined" + ], + "UwtPiiValuePattern": [ + "type = 'email' | 'creditCard' | 'ssn' | 'ipv4' | 'phone'" + ], + "UwtRedactConfig": [ + "interface", + "extraKeyPatterns: RegExp[]", + "extraValuePatterns: RegExp[]", + "extraValueTransforms: ((value: string) => string)[]", + "includeStack: boolean", + "maxKeys: number", + "maxStackLength: number", + "maxStringLength: number", + "scanValuePatterns: UwtPiiValuePattern[]", + "stripUrlQuery: boolean" + ], + "uwtRedactConfigFor": [ + "function(config: UwtRedactConfig, enabled: boolean): UwtRedactConfig" + ], + "uwtRedactMetadata": [ + "function(value: unknown, config?: UwtRedactConfig | undefined): UwtTelemetryMetadata | undefined" + ], + "UwtScenario": [ + "class", + "new (options: UwtScenarioOptions, deps: UwtScenarioDeps): UwtScenario", + "aggregateEnd(name: string): UwtScenario", + "aggregateStart(name: string): UwtScenario", + "cancel(outcome?: UwtScenarioOutcome | undefined): void", + "complete(outcome?: UwtScenarioOutcome | undefined): void", + "endStep(detail?: UwtScenarioStepDetail | undefined): UwtScenario", + "fail(outcome?: UwtScenarioOutcome | undefined): void", + "failStep(error: unknown, detail?: UwtScenarioStepDetail | undefined): UwtScenario", + "get delta: number", + "get id: string", + "get isSettled: boolean", + "get name: string", + "get status: UwtScenarioStatus | undefined", + "incomplete(outcome?: UwtScenarioOutcome | undefined): void", + "setData(metadata: UwtTelemetryMetadata): UwtScenario", + "settle(status: UwtScenarioStatus, outcome?: UwtScenarioOutcome | undefined, error?: unknown): void", + "step(name: string, metadata?: UwtTelemetryMetadata | undefined): UwtScenario", + "toRecord(): UwtScenarioRecord" + ], + "UwtScenarioAggregate": [ + "interface", + "callCount: number", + "elapsed: number", + "name: string" + ], + "UwtScenarioName": [ + "type = string" + ], + "UwtScenarioNames": [ + "interface" + ], + "UwtScenarioOptions": [ + "interface", + "feature?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "name: string", + "operation?: string | undefined", + "parentScenarioId?: string | undefined", + "route?: string | undefined", + "startedAt?: number | undefined", + "timeoutMs?: number | undefined" + ], + "UwtScenarioOutcome": [ + "interface", + "error?: unknown", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "reason?: string | undefined", + "statusCode?: string | number | undefined" + ], + "UwtScenarioRecord": [ + "interface", + "aggregates?: UwtScenarioAggregate[] | undefined", + "application: string", + "delta: number", + "elapsed: number", + "endTime?: string | undefined", + "error?: UwtTelemetryError | undefined", + "exceededStepsLimit?: boolean | undefined", + "feature?: string | undefined", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "operation?: string | undefined", + "parentScenarioId?: string | undefined", + "previousStep?: string | undefined", + "reason?: string | undefined", + "route?: string | undefined", + "scenarioId: string", + "scenarioName: string", + "sessionElapsed?: number | undefined", + "sessionId?: string | undefined", + "startTime: string", + "status?: UwtScenarioStatus | undefined", + "statusCode?: string | number | undefined", + "stepCount: number", + "steps: UwtScenarioStep[]", + "userId?: string | undefined" + ], + "UwtScenarioStatus": [ + "type = 'success' | 'failure' | 'abandoned' | 'incomplete' | 'timeout'" + ], + "UwtScenarioStep": [ + "interface", + "elapsed?: number | undefined", + "endOffset?: number | undefined", + "error?: UwtTelemetryError | undefined", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "name: string", + "reason?: string | undefined", + "startOffset: number", + "status?: UwtScenarioStatus | undefined", + "stepDelta?: number | undefined" + ], + "UwtScenarioStepDetail": [ + "interface", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "reason?: string | undefined" + ], + "UwtScenarioStepEvent": [ + "interface", + "application: string", + "elapsed?: number | undefined", + "endOffset?: number | undefined", + "error?: UwtTelemetryError | undefined", + "message?: string | undefined", + "metadata?: UwtTelemetryMetadata | undefined", + "name: string", + "reason?: string | undefined", + "scenarioId: string", + "scenarioName: string", + "sessionId?: string | undefined", + "startOffset: number", + "status?: UwtScenarioStatus | undefined", + "stepDelta?: number | undefined", + "userId?: string | undefined" + ], + "UwtScenarioSteps": [ + "interface" + ], + "UwtScenarioStepStatus": [ + "type = 'success' | 'failure' | 'abandoned' | 'incomplete' | 'timeout'" + ], + "UwtScenarioTelemetryConfig": [ + "interface", + "defaultTimeoutMs: number", + "emitLifecycleEvents: boolean", + "markCleanupFallbackMs: number", + "maxSteps: number", + "redact: boolean", + "userTimings: boolean" + ], + "UwtScenarioTelemetryService": [ + "class", + "new (): UwtScenarioTelemetryService", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "find(scenarioId: string): UwtScenario | undefined", + "findByName(name: string): UwtScenario[]", + "findByNameAndId(name: string, scenarioId: string): UwtScenario | undefined", + "getActive(): UwtScenario[]", + "ngOnDestroy(): void", + "readonly settled$: Observable", + "start(options: UwtScenarioOptions): UwtScenario" + ], + "uwtScrubString": [ + "function(value: string, config: UwtRedactConfig): string" + ], + "UwtStepName": [ + "type = string" + ], + "UwtTelemetryAttribution": [ + "interface", + "application: string", + "sessionId?: string | undefined", + "userId?: string | undefined" + ], + "UwtTelemetryBroadcastHost": [ + "class", + "new (): UwtTelemetryBroadcastHost", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "get received: number", + "ngOnDestroy(): void" + ], + "UwtTelemetryDestinationOptions": [ + "interface", + "init?: ((sink: T) => void) | undefined" + ], + "UwtTelemetryError": [ + "interface", + "message: string", + "name: string", + "stack?: string | undefined" + ], + "UwtTelemetryEventTypes": [ + "interface", + "bi: string", + "scenario: string", + "scenarioStep: string" + ], + "UwtTelemetryFeature": [ + "interface", + "providers: Provider[]" + ], + "UwtTelemetryIdentity": [ + "interface", + "application: string", + "environment: string", + "eventNamespace?: string | undefined", + "version: string" + ], + "UwtTelemetryLocalSinkMode": [ + "type = 'broadcast' | 'noop'" + ], + "UwtTelemetryMetadata": [ + "type = { [x: string]: string | number | boolean | null; }" + ], + "UwtTelemetrySink": [ + "abstract class", + "new (): UwtTelemetrySink", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "abstract flush(beacon?: boolean | undefined): void", + "abstract getSessionId(): string | undefined", + "abstract getUserId(): string | undefined", + "abstract record(eventType: string, payload: object, metadata?: UwtTelemetryMetadata | undefined): void", + "abstract recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata | undefined): void", + "abstract setUserId(userId: string | undefined): void" + ], + "UwtTelemetrySinkEvent": [ + "type = { kind: 'scenario'; payload: UwtScenarioRecord; } | { kind: 'scenario-step'; payload: UwtScenarioStepEvent; } | { kind: 'bi'; payload: UwtBIEvent; } | { kind: 'unknown'; payload: object; }" + ], + "UwtTraceScenarioOptions": [ + "interface", + "cancelOutcome?: UwtScenarioOutcome | (() => void | UwtScenarioOutcome) | undefined", + "outcome?: ((value: T) => void | UwtScenarioOutcome) | undefined", + "valueThenTeardown?: 'complete' | 'cancel' | undefined" + ], + "uwtUuid": [ + "function(): string" + ], + "withBIEvents": [ + "function(config?: Partial | undefined): UwtTelemetryFeature" + ], + "withLogging": [ + "function(config?: Partial | undefined): UwtTelemetryFeature" + ], + "withRedaction": [ + "function(config?: Partial | undefined): UwtTelemetryFeature" + ], + "withScenarios": [ + "function(config?: Partial | undefined): UwtTelemetryFeature" + ], + "withTelemetryEnabled": [ + "function(enabled: boolean | (() => boolean)): UwtTelemetryFeature" + ] + }, + "@absaoss-cps/ngx-ui-watchtower/rum": { + "provideUwtTelemetryRumSink": [ + "function(): EnvironmentProviders" + ], + "UWT_RUM_CREDENTIALS_PROVIDER": [ + "const: InjectionToken" + ], + "UwtRumAppMonitorConfig": [ + "interface", + "alias?: string | undefined", + "allowCookies?: boolean | undefined", + "applicationAttributes?: Record | undefined", + "applicationId: string", + "applicationVersion: string", + "batchLimit?: number | undefined", + "candidatesCacheSize?: number | undefined", + "client?: string | undefined", + "clientBuilder?: ((endpoint: URL, region: string, credentials?: ClientBuilderCredentials | undefined, compressionStrategy?: { enabled: boolean; } | undefined) => unknown) | undefined", + "compressionStrategy?: { enabled: boolean; } | undefined", + "cookieAttributes?: { unique?: boolean | undefined; domain?: string | undefined; path?: string | undefined; sameSite?: string | undefined; secure?: boolean | undefined; } | undefined", + "debug?: boolean | undefined", + "disableAutoPageView?: boolean | undefined", + "dispatchInterval?: number | undefined", + "enableRumClient?: boolean | undefined", + "enableW3CTraceId?: boolean | undefined", + "enableXRay?: boolean | undefined", + "endpoint?: string | undefined", + "eventCacheSize?: number | undefined", + "eventPluginsToLoad?: unknown[] | undefined", + "fetchFunction?: ((input: RequestInfo | URL, init?: RequestInit | undefined) => Promise) | undefined", + "headers?: Record | undefined", + "pageIdFormat?: 'PATH' | 'HASH' | 'PATH_AND_HASH' | undefined", + "pagesToExclude?: RegExp[] | undefined", + "pagesToInclude?: RegExp[] | undefined", + "recordResourceUrl?: boolean | undefined", + "region: string", + "releaseId?: string | undefined", + "retries?: number | undefined", + "routeChangeComplete?: number | undefined", + "routeChangeTimeout?: number | undefined", + "sessionAttributes?: Record | undefined", + "sessionEventLimit?: number | undefined", + "sessionLengthSeconds?: number | undefined", + "sessionSampleRate?: number | undefined", + "signing?: boolean | undefined", + "suppressSessionStartEvent?: boolean | undefined", + "telemetries?: (string | (string | object)[])[] | undefined", + "useBeacon?: boolean | undefined", + "userIdRetentionDays?: number | undefined" + ], + "UwtRumBootstrap": [ + "interface", + "config: UwtRumAppMonitorConfig", + "credentials?: UwtRumCredentials | undefined" + ], + "UwtRumCredentials": [ + "interface", + "accessKeyId: string", + "expiration: string", + "secretAccessKey: string", + "sessionToken: string" + ], + "UwtRumCredentialsProvider": [ + "interface", + "load(): Promise" + ], + "UwtRumTelemetrySink": [ + "class", + "new (): UwtRumTelemetrySink", + "static ɵfac: unknown", + "static ɵprov: ɵɵInjectableDeclaration", + "flush(beacon?: boolean | undefined): void", + "getSessionId(): string | undefined", + "getUserId(): string | undefined", + "init(): Promise", + "ngOnDestroy(): void", + "record(eventType: string, payload: object, metadata?: UwtTelemetryMetadata | undefined): void", + "recordError(error: UwtTelemetryError, metadata?: UwtTelemetryMetadata | undefined): void", + "recordPageView(pageId: string): void", + "setUserId(userId: string | undefined): void" + ] + } +} diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json new file mode 100644 index 0000000..116f843 --- /dev/null +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.fixture.json @@ -0,0 +1,254 @@ +[ + { + "from": "shell", + "message": { + "kind": "identity", + "sessionId": "shell-session", + "userId": "" + } + }, + { + "from": "fragment", + "message": { + "kind": "identity-request" + } + }, + { + "from": "shell", + "message": { + "kind": "identity", + "sessionId": "shell-session", + "userId": "" + } + }, + { + "from": "fragment", + "message": { + "eventType": "com.uwt.scenario", + "kind": "event", + "metadata": { + "appVersion": "1.0.0", + "application": "realm", + "environment": "test" + }, + "payload": { + "aggregates": "", + "application": "realm", + "delta": "", + "elapsed": "", + "endTime": "", + "feature": "", + "metadata": { + "cart": "c-1", + "items": 2 + }, + "operation": "", + "parentScenarioId": "", + "previousStep": "pay", + "route": "", + "scenarioId": "", + "scenarioName": "checkout", + "sessionId": "shell-session", + "startTime": "", + "status": "success", + "stepCount": 1, + "steps": [ + { + "elapsed": "", + "endOffset": "", + "name": "scenario-start", + "startOffset": "", + "status": "success", + "stepDelta": "" + }, + { + "elapsed": "", + "endOffset": "", + "metadata": { + "method": "card" + }, + "name": "pay", + "startOffset": "", + "status": "success", + "stepDelta": "" + }, + { + "elapsed": "", + "endOffset": "", + "metadata": { + "items": 2 + }, + "name": "scenario-end", + "startOffset": "", + "status": "success", + "stepDelta": "" + } + ], + "userId": "" + } + } + }, + { + "from": "fragment", + "message": { + "eventType": "com.uwt.bi", + "kind": "event", + "metadata": { + "appVersion": "1.0.0", + "application": "realm", + "environment": "test" + }, + "payload": { + "application": "realm", + "eventName": "export_clicked", + "eventTime": "", + "feature": "", + "metadata": { + "format": "csv" + }, + "scenarioId": "", + "sessionId": "shell-session", + "userId": "" + } + } + }, + { + "from": "fragment", + "message": { + "kind": "log", + "record": { + "application": "realm", + "context": "", + "correlationId": "", + "environment": "test", + "error": "", + "level": "warn", + "logger": "checkout", + "message": "Slow payment", + "metadata": { + "ms": 900 + }, + "sessionId": "shell-session", + "timestamp": "", + "userId": "", + "version": "1.0.0" + } + } + }, + { + "from": "fragment", + "message": { + "kind": "log", + "record": { + "application": "realm", + "context": "", + "correlationId": "", + "environment": "test", + "error": { + "message": "Declined", + "name": "Error", + "stack": "" + }, + "level": "error", + "logger": "checkout", + "message": "Payment failed", + "metadata": "", + "sessionId": "shell-session", + "timestamp": "", + "userId": "", + "version": "1.0.0" + } + } + }, + { + "from": "fragment", + "message": { + "kind": "user", + "userId": "user-1" + } + }, + { + "from": "shell", + "message": { + "kind": "identity", + "sessionId": "shell-session", + "userId": "user-1" + } + }, + { + "from": "fragment", + "message": { + "beacon": true, + "kind": "flush" + } + }, + { + "from": "fragment", + "message": { + "kind": "log-flush" + } + }, + { + "from": "fragment", + "message": { + "filter": { + "limit": 1, + "logger": "checkout" + }, + "id": "", + "kind": "log-query" + } + }, + { + "from": "shell", + "message": { + "id": "", + "kind": "log-query-result", + "records": [ + { + "application": "realm", + "environment": "test", + "level": "warn", + "logger": "checkout", + "message": "Slow payment", + "metadata": { + "ms": 900 + }, + "sessionId": "shell-session", + "timestamp": "", + "version": "1.0.0" + } + ] + } + }, + { + "from": "fragment", + "message": { + "eventType": "com.uwt.bi", + "kind": "event", + "metadata": { + "appVersion": "1.0.0", + "application": "realm", + "environment": "test" + }, + "payload": { + "application": "realm", + "eventName": "theme_changed", + "eventTime": "", + "feature": "", + "metadata": "", + "scenarioId": "", + "sessionId": "shell-session", + "userId": "user-1" + } + } + }, + { + "from": "shell", + "message": { + "kind": "identity", + "sessionId": "shell-session-2", + "userId": "user-1" + } + } +] diff --git a/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts new file mode 100644 index 0000000..65a5fe0 --- /dev/null +++ b/projects/ngx-ui-watchtower/src/lib/sinks/uwt-broadcast/uwt-broadcast.wire.spec.ts @@ -0,0 +1,327 @@ +import { DOCUMENT } from '@angular/common'; +import { Injectable, Injector, PLATFORM_ID } from '@angular/core'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { + UWT_DEFAULT_TELEMETRY_CONFIG, + UWT_REDACT_CONFIG, + UWT_TELEMETRY_IDENTITY +} from '../../config/uwt-telemetry-common.config/uwt-telemetry-common.config'; +import { UWT_BI_TELEMETRY_CONFIG } from '../../config/uwt-bi-telemetry.config/uwt-bi-telemetry.config'; +import { UWT_LOG_CONFIG } from '../../config/uwt-log.config/uwt-log.config'; +import { UWT_SCENARIO_TELEMETRY_CONFIG } from '../../config/uwt-scenario-telemetry.config/uwt-scenario-telemetry.config'; +import { + UwtTelemetryError, + UwtTelemetryMetadata +} from '../../models/uwt-telemetry-common.models/uwt-telemetry-common.models'; +import { UwtLogRecord } from '../../models/uwt-log.models/uwt-log.models'; +import { + UWT_LOG_API_PROVIDER, + UwtLogApiProvider, + UwtLogQuery +} from '../../providers/uwt-log-api.provider/uwt-log-api.provider'; +import { UwtBroadcastLogApiProvider } from '../../providers/uwt-broadcast-log-api.provider/uwt-broadcast-log-api.provider'; +import { UwtBITelemetryService } from '../../services/uwt-bi-telemetry.service/uwt-bi-telemetry.service'; +import { UwtLoggerService } from '../../services/uwt-logger.service/uwt-logger.service'; +import { UwtScenarioTelemetryService } from '../../services/uwt-scenario-telemetry.service/uwt-scenario-telemetry.service'; +import { UwtTelemetrySink } from '../uwt-telemetry/uwt-telemetry-abstract.sink/uwt-telemetry-abstract.sink'; +import { UwtTelemetryBroadcastHost } from './uwt-broadcast-host.service'; +import { UwtBroadcastTelemetrySink } from './uwt-broadcast-telemetry.sink'; + +/** + * The broadcast protocol is a contract between realms deployed separately: + * a shell and its fragments may run different versions. This records every + * message a shell and a fragment exchange through one full session, and + * checks it against the fixture recorded from the implementation before the + * core was extracted. Record it again only deliberately, with + * `UPDATE_WIRE_FIXTURE=1`. + */ +const FIXTURE = join(__dirname, 'uwt-broadcast.wire.fixture.json'); + +/** A message as posted: by which realm, and what it carried. */ +interface Posted { + from: string; + message: unknown; +} + +/** + * `BroadcastChannel` for the test, delivering asynchronously to every other + * instance of the same name, and keeping each message as posted. + */ +class WireChannel { + /** The realm the next channel belongs to. */ + static realm = ''; + static readonly posted: Posted[] = []; + private static readonly channels = new Map(); + + onmessage: ((event: { data: unknown }) => void) | null = null; + private readonly realm = WireChannel.realm; + private closed = false; + + constructor(readonly name: string) { + WireChannel.channels.set(name, [ + ...(WireChannel.channels.get(name) ?? []), + this + ]); + } + + static reset(): void { + WireChannel.posted.length = 0; + WireChannel.channels.clear(); + } + + postMessage(message: unknown): void { + if (this.closed) { + return; + } + WireChannel.posted.push({ from: this.realm, message: plain(message) }); + for (const peer of WireChannel.channels.get(this.name) ?? []) { + if (peer !== this) { + const data = JSON.parse(JSON.stringify(message)); + setTimeout(() => !peer.closed && peer.onmessage?.({ data }), 0); + } + } + } + + close(): void { + this.closed = true; + WireChannel.channels.set( + this.name, + (WireChannel.channels.get(this.name) ?? []).filter((c) => c !== this) + ); + } +} + +/** + * A message as structured clone carries it: keys in a stable order, and a + * key whose value is `undefined` kept, as clone keeps it and JSON doesn't. + */ +function plain(value: unknown): unknown { + if (value === undefined) { + return ''; + } + if (Array.isArray(value)) { + return value.map(plain); + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, plain((value as Record)[key])]) + ); + } + return value; +} + +/** + * Fields whose value is a time, an offset or a duration — `eventTime`, + * `endOffset`, `stepDelta`, `sessionElapsed`… — which differ run to run. + */ +const TIMED = /(^|[a-z])(time|offset|delta|elapsed|duration|timestamp)$/i; + +/** A stack trace names files and lines, which differ by machine and by edit. */ +const VARIES = (key: string): boolean => TIMED.test(key) || key === 'stack'; + +/** + * What can differ between runs made comparable: ids numbered by first + * appearance, and times, durations and stack traces by their type. + */ +function stable(posted: Posted[]): Posted[] { + const uuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const ids = new Map(); + const walk = (value: unknown, key = ''): unknown => { + if (VARIES(key) && value !== '') { + return `<${typeof value}>`; + } + if (typeof value === 'string' && uuid.test(value)) { + if (!ids.has(value)) { + ids.set(value, ``); + } + return ids.get(value); + } + if (Array.isArray(value)) { + return value.map((v) => walk(v)); + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, walk(v, k)]) + ); + } + return value; + }; + return walk(posted) as Posted[]; +} + +/** The shell's real destination, with a session and user it controls. */ +@Injectable() +class ShellSink extends UwtTelemetrySink { + sessionId: string | undefined = 'shell-session'; + userId?: string; + + record(_eventType: string, _payload: object, _meta?: UwtTelemetryMetadata) { + // Recorded on the wire, not here. + } + + recordError(_error: UwtTelemetryError, _meta?: UwtTelemetryMetadata) { + // Recorded on the wire, not here. + } + + getSessionId(): string | undefined { + return this.sessionId; + } + + setUserId(userId: string | undefined): void { + this.userId = userId; + } + + getUserId(): string | undefined { + return this.userId; + } + + flush(): void { + // Nothing buffered. + } +} + +/** The shell's log backend, answering queries with what it has. */ +@Injectable() +class ShellLogApi implements UwtLogApiProvider { + readonly records: UwtLogRecord[] = []; + + send(record: UwtLogRecord): void { + this.records.push(record); + } + + query(filter: UwtLogQuery): Promise { + return Promise.resolve( + this.records + .filter((r) => !filter.logger || r.logger === filter.logger) + .slice(0, filter.limit) + ); + } + + flush(): void { + // Nothing buffered. + } +} + +/** A realm: its own injector, as the shell and each fragment have. */ +function createRealm(providers: unknown[]): Injector { + return Injector.create({ + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + { provide: DOCUMENT, useValue: document }, + { + provide: UWT_TELEMETRY_IDENTITY, + useValue: { + application: 'realm', + environment: 'test', + version: '1.0.0', + eventNamespace: 'com.uwt' + } + }, + { + provide: UWT_SCENARIO_TELEMETRY_CONFIG, + useValue: UWT_DEFAULT_TELEMETRY_CONFIG.scenario + }, + { provide: UWT_LOG_CONFIG, useValue: UWT_DEFAULT_TELEMETRY_CONFIG.logs }, + { + provide: UWT_BI_TELEMETRY_CONFIG, + useValue: UWT_DEFAULT_TELEMETRY_CONFIG.bi + }, + { + provide: UWT_REDACT_CONFIG, + useValue: UWT_DEFAULT_TELEMETRY_CONFIG.redact + }, + UwtLoggerService, + UwtScenarioTelemetryService, + UwtBITelemetryService, + ...(providers as never[]) + ] + }); +} + +describe('the broadcast wire protocol', () => { + beforeEach(() => { + localStorage.clear(); + WireChannel.reset(); + Object.defineProperty(globalThis, 'BroadcastChannel', { + value: WireChannel, + configurable: true, + writable: true + }); + }); + + afterEach(() => { + delete (globalThis as { BroadcastChannel?: unknown }).BroadcastChannel; + }); + + /** Lets every delivery, and every reply it causes, arrive. */ + async function settle(): Promise { + for (let turn = 0; turn < 6; turn++) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + it('should carry what a shell and a fragment exchange, unchanged', async () => { + WireChannel.realm = 'shell'; + const shell = createRealm([ + ShellSink, + { provide: UwtTelemetrySink, useExisting: ShellSink }, + ShellLogApi, + { provide: UWT_LOG_API_PROVIDER, useExisting: ShellLogApi }, + UwtTelemetryBroadcastHost + ]); + const shellSink = shell.get(ShellSink); + const host = shell.get(UwtTelemetryBroadcastHost); + + WireChannel.realm = 'fragment'; + const fragment = createRealm([ + UwtBroadcastTelemetrySink, + { provide: UwtTelemetrySink, useExisting: UwtBroadcastTelemetrySink }, + UwtBroadcastLogApiProvider, + { provide: UWT_LOG_API_PROVIDER, useExisting: UwtBroadcastLogApiProvider } + ]); + const sink = fragment.get(UwtTelemetrySink); + const logApi = fragment.get(UWT_LOG_API_PROVIDER); + await settle(); + + // A scenario with a step, a business event, logs and a mirrored error. + const scenario = fragment + .get(UwtScenarioTelemetryService) + .start({ name: 'checkout', metadata: { cart: 'c-1' } }); + scenario.step('pay', { method: 'card' }); + scenario.endStep(); + scenario.complete({ metadata: { items: 2 } }); + fragment + .get(UwtBITelemetryService) + .track('export_clicked', { format: 'csv' }); + const logger = fragment.get(UwtLoggerService).getLogger('checkout'); + logger.warn('Slow payment', { metadata: { ms: 900 } }); + logger.error('Payment failed', { error: new Error('Declined') }); + await settle(); + + // A sign-in, flushes, and a query answered by the shell's backend. + sink.setUserId('user-1'); + await settle(); + sink.flush(true); + logApi.flush?.(); + const found = logApi.query({ logger: 'checkout', limit: 1 }); + await settle(); + expect(await found).toHaveLength(1); + + // The shell's session moves on: the next record is stamped with it. + shellSink.sessionId = 'shell-session-2'; + fragment.get(UwtBITelemetryService).track('theme_changed'); + await settle(); + + host.ngOnDestroy(); + const recorded = stable(WireChannel.posted); + if (process.env.UPDATE_WIRE_FIXTURE === '1') { + writeFileSync(FIXTURE, JSON.stringify(recorded, null, 2) + '\n'); + } + expect(existsSync(FIXTURE)).toBe(true); + expect(recorded).toEqual(JSON.parse(readFileSync(FIXTURE, 'utf8'))); + }); +}); diff --git a/projects/ngx-ui-watchtower/tsconfig.spec.json b/projects/ngx-ui-watchtower/tsconfig.spec.json index 16f49dc..1748c35 100644 --- a/projects/ngx-ui-watchtower/tsconfig.spec.json +++ b/projects/ngx-ui-watchtower/tsconfig.spec.json @@ -4,7 +4,7 @@ "compilerOptions": { "outDir": "../../out-tsc/spec", "rootDir": "./src", - "types": ["jest"], + "types": ["jest", "node"], "resolveJsonModule": true }, "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] diff --git a/projects/ngx-ui-watchtower/type-tests/augmented.ts b/projects/ngx-ui-watchtower/type-tests/augmented.ts new file mode 100644 index 0000000..8a6ef5c --- /dev/null +++ b/projects/ngx-ui-watchtower/type-tests/augmented.ts @@ -0,0 +1,138 @@ +/** + * Module augmentation exactly as a consumer writes it: the four registries + * augmented on `@absaoss-cps/ngx-ui-watchtower`. Registered names must reach + * every API that takes or returns a name, and anything else must be + * rejected. + * + * Compiled by `npm run typecheck` on its own, never run. An unused + * `@ts-expect-error` fails the compilation, so a name that stops being + * rejected is caught too. + */ +import { inject } from '@angular/core'; +import { ObservedValueOf } from 'rxjs'; +import { + UwtBIEvent, + UwtBIEventName, + UwtBITelemetryService, + UwtLogConfig, + UwtLoggerName, + UwtLoggerService, + UwtLogQuery, + UwtLogRecord, + UwtScenario, + UwtScenarioAggregate, + UwtScenarioName, + UwtScenarioOptions, + UwtScenarioRecord, + UwtScenarioStep, + UwtScenarioStepEvent, + UwtScenarioTelemetryService, + UwtStepName, + withLogging +} from '@absaoss-cps/ngx-ui-watchtower'; + +declare module '@absaoss-cps/ngx-ui-watchtower' { + interface UwtScenarioNames { + checkout: true; + 'load-dashboard': true; + } + interface UwtScenarioSteps { + pay: true; + confirm: true; + } + interface UwtBIEventNames { + export_clicked: true; + } + interface UwtLoggerNames { + checkout: true; + } +} + +type Equals = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; + +function expectType(): T | void { + // Checked at compile time only. +} + +// The names are exactly the registered ones. +expectType>(); +expectType>(); +expectType>(); +expectType>(); + +// And they reach every model that carries a name. +expectType>(); +expectType>(); +expectType< + Equals +>(); +expectType< + Equals< + UwtScenarioStep['name'], + UwtStepName | 'scenario-start' | 'scenario-end' + > +>(); +expectType>(); +expectType>(); +expectType>(); +expectType>(); +expectType>(); +expectType>(); +expectType< + Equals< + UwtLogConfig['levels'], + Partial> | undefined + > +>(); + +/** Registered names accepted, everything else rejected. */ +export class Usage { + private readonly scenarios = inject(UwtScenarioTelemetryService); + private readonly bi = inject(UwtBITelemetryService); + private readonly logs = inject(UwtLoggerService); + + accepted(): unknown[] { + const scenario = this.scenarios.start({ name: 'checkout' }); + scenario.step('pay').step('confirm').endStep(); + scenario.aggregateStart('pay').aggregateEnd('pay'); + this.bi.track('export_clicked', { format: 'csv' }); + this.logs.getLogger('checkout').warn('Slow payment'); + const settled = this.scenarios.settled$; + expectType< + Equals['scenarioName'], UwtScenarioName> + >(); + return [ + settled, + this.scenarios.findByName('load-dashboard'), + this.scenarios.findByNameAndId('checkout', 'id'), + this.logs.query({ logger: 'checkout' }), + withLogging({ levels: { checkout: 'log' } }) + ]; + } + + rejected(scenario: UwtScenario): void { + // @ts-expect-error not a registered scenario + this.scenarios.start({ name: 'chekout' }); + // @ts-expect-error not a registered scenario + this.scenarios.findByName('chekout'); + // @ts-expect-error not a registered scenario + this.scenarios.findByNameAndId('chekout', 'id'); + // @ts-expect-error not a registered step + scenario.step('pai'); + // @ts-expect-error not a registered step + scenario.aggregateStart('pai'); + // @ts-expect-error not a registered step + scenario.aggregateEnd('pai'); + // @ts-expect-error not a registered business event + this.bi.track('export_clikced'); + // @ts-expect-error not a registered logger + this.logs.getLogger('chekout'); + // @ts-expect-error not a registered logger + this.logs.query({ logger: 'chekout' }); + // @ts-expect-error not a registered logger + withLogging({ levels: { chekout: 'log' } }); + } +} diff --git a/projects/ngx-ui-watchtower/type-tests/compat.ts b/projects/ngx-ui-watchtower/type-tests/compat.ts new file mode 100644 index 0000000..97cf617 --- /dev/null +++ b/projects/ngx-ui-watchtower/type-tests/compat.ts @@ -0,0 +1,342 @@ +/** + * Every public export of `@absaoss-cps/ngx-ui-watchtower` and its `/rum` + * entry point, used the way an application or an integration uses it. + * + * Compiled by `npm run typecheck`, never run. It must keep compiling + * unchanged: that is what "no consumer change" means for the types. No + * registry is augmented here, so every name is a plain string; see + * `augmented.ts` for registered names. + */ +import { ApplicationConfig, inject, Injectable } from '@angular/core'; +import { Observable, of } from 'rxjs'; +import { + provideUwtTelemetry, + provideUwtTelemetryBroadcastHost, + provideUwtTelemetryDestination, + provideUwtTelemetrySink, + traceScenario, + UWT_BI_TELEMETRY_CONFIG, + UWT_BROADCAST_CHANNEL, + UWT_DEFAULT_BROADCAST_CHANNEL, + UWT_DEFAULT_EVENT_NAMESPACE, + UWT_DEFAULT_REDACT_CONFIG, + UWT_DEFAULT_TELEMETRY_CONFIG, + UWT_LOG_API_PROVIDER, + UWT_LOG_CONFIG, + UWT_LOG_LEVEL_ORDER, + UWT_REDACT_CONFIG, + UWT_REDACTED, + UWT_SCENARIO_TELEMETRY_CONFIG, + UWT_TELEMETRY_ENABLED, + UWT_TELEMETRY_EVENT_TYPE, + UWT_TELEMETRY_IDENTITY, + UwtBIEvent, + UwtBIEventDetail, + UwtBIEventName, + UwtBIEventNames, + UwtBITelemetryConfig, + UwtBITelemetryService, + UwtBroadcastLogApiProvider, + UwtBroadcastTelemetrySink, + uwtClassifyTelemetryEvent, + UwtDebugFlag, + uwtEventTypes, + uwtIsDebugEnabled, + UwtLogApiProvider, + UwtLogConfig, + UwtLogDetail, + UwtLogger, + UwtLoggerName, + UwtLoggerNames, + UwtLoggerService, + UwtLogLevel, + UwtLogQuery, + UwtLogRecord, + uwtMarkSessionStart, + uwtNormalizeError, + UwtNoopLogApiProvider, + UwtNoopTelemetrySink, + UwtPiiValuePattern, + UwtRedactConfig, + uwtRedactConfigFor, + uwtRedactMetadata, + UwtScenario, + UwtScenarioAggregate, + UwtScenarioName, + UwtScenarioNames, + UwtScenarioOptions, + UwtScenarioOutcome, + UwtScenarioRecord, + UwtScenarioStatus, + UwtScenarioStep, + UwtScenarioStepDetail, + UwtScenarioStepEvent, + UwtScenarioStepStatus, + UwtScenarioSteps, + UwtScenarioTelemetryConfig, + UwtScenarioTelemetryService, + uwtScrubString, + UwtStepName, + UwtTelemetryAttribution, + UwtTelemetryBroadcastHost, + UwtTelemetryDestinationOptions, + UwtTelemetryError, + UwtTelemetryEventTypes, + UwtTelemetryFeature, + UwtTelemetryIdentity, + UwtTelemetryLocalSinkMode, + UwtTelemetryMetadata, + UwtTelemetrySink, + UwtTelemetrySinkEvent, + UwtTraceScenarioOptions, + uwtUuid, + withBIEvents, + withLogging, + withRedaction, + withScenarios, + withTelemetryEnabled +} from '@absaoss-cps/ngx-ui-watchtower'; +import { + provideUwtTelemetryRumSink, + UWT_RUM_CREDENTIALS_PROVIDER, + UwtRumAppMonitorConfig, + UwtRumBootstrap, + UwtRumCredentials, + UwtRumCredentialsProvider, + UwtRumTelemetrySink +} from '@absaoss-cps/ngx-ui-watchtower/rum'; + +/** A destination an application writes itself. */ +@Injectable() +export class AppSink extends UwtTelemetrySink { + record(_type: string, _payload: object, _meta?: UwtTelemetryMetadata): void { + // Sent elsewhere. + } + + recordError(_error: UwtTelemetryError, _meta?: UwtTelemetryMetadata): void { + // Sent elsewhere. + } + + getSessionId(): string | undefined { + return undefined; + } + + setUserId(_userId: string | undefined): void { + // Kept elsewhere. + } + + getUserId(): string | undefined { + return undefined; + } + + flush(_beacon?: boolean): void { + // Nothing buffered. + } +} + +/** A log backend. */ +@Injectable() +export class AppLogBackend implements UwtLogApiProvider { + readonly ranks: number[] = []; + + send(record: UwtLogRecord): void { + const level: UwtLogLevel = record.level; + this.ranks.push(UWT_LOG_LEVEL_ORDER[level]); + } + + query(_filter: UwtLogQuery): Promise { + return Promise.resolve([]); + } + + flush(): void { + // Nothing buffered. + } +} + +/** RUM credentials from the application's broker. */ +@Injectable() +export class AppRumCredentials implements UwtRumCredentialsProvider { + async load(): Promise { + const config: UwtRumAppMonitorConfig = { + applicationId: 'app-monitor', + region: 'eu-west-1', + applicationVersion: '1.0.0' + }; + const credentials: UwtRumCredentials = { + accessKeyId: 'a', + secretAccessKey: 's', + sessionToken: 't', + expiration: '2026-01-01T00:00:00Z' + }; + return { config, credentials }; + } +} + +const identity: UwtTelemetryIdentity = { + application: 'app', + environment: 'prod', + version: '1.0.0' +}; +const logConfig: Partial = { minLevel: 'warn' }; +const scenarioConfig: Partial = { maxSteps: 10 }; +const biConfig: Partial = {}; +const redactConfig: Partial = {}; +const features: UwtTelemetryFeature[] = [ + withLogging(logConfig), + withScenarios(scenarioConfig), + withBIEvents(biConfig), + withRedaction(redactConfig), + withTelemetryEnabled(() => true) +]; +const destination: UwtTelemetryDestinationOptions = { + init: (sink) => sink.flush() +}; +const mode: UwtTelemetryLocalSinkMode = 'broadcast'; + +/** Each way an application wires the library. */ +export const configs: ApplicationConfig[] = [ + { + providers: [ + provideUwtTelemetry(identity, ...features), + provideUwtTelemetryDestination(AppSink, destination), + { provide: UWT_LOG_API_PROVIDER, useClass: AppLogBackend } + ] + }, + { + providers: [ + provideUwtTelemetry(identity), + provideUwtTelemetrySink(mode, { channelName: 'app' }), + provideUwtTelemetrySink('noop') + ] + }, + { + providers: [ + provideUwtTelemetry(identity), + provideUwtTelemetryRumSink(), + { provide: UWT_RUM_CREDENTIALS_PROVIDER, useClass: AppRumCredentials }, + { provide: UWT_LOG_API_PROVIDER, useClass: UwtNoopLogApiProvider }, + provideUwtTelemetryBroadcastHost('app') + ] + }, + { + providers: [ + { provide: UWT_TELEMETRY_IDENTITY, useValue: identity }, + { + provide: UWT_LOG_CONFIG, + useValue: UWT_DEFAULT_TELEMETRY_CONFIG.logs + }, + { + provide: UWT_SCENARIO_TELEMETRY_CONFIG, + useValue: UWT_DEFAULT_TELEMETRY_CONFIG.scenario + }, + { + provide: UWT_BI_TELEMETRY_CONFIG, + useValue: UWT_DEFAULT_TELEMETRY_CONFIG.bi + }, + { provide: UWT_REDACT_CONFIG, useValue: UWT_DEFAULT_REDACT_CONFIG }, + { provide: UWT_TELEMETRY_ENABLED, useValue: true }, + { + provide: UWT_BROADCAST_CHANNEL, + useValue: UWT_DEFAULT_BROADCAST_CHANNEL + } + ] + } +]; + +/** The facades, as application code injects and calls them. */ +export class Checkout { + private readonly scenarios = inject(UwtScenarioTelemetryService); + private readonly bi = inject(UwtBITelemetryService); + private readonly logger: UwtLogger = inject(UwtLoggerService).getLogger('x'); + private readonly logs = inject(UwtLoggerService); + readonly classes = [ + UwtBroadcastTelemetrySink, + UwtNoopTelemetrySink, + UwtRumTelemetrySink, + UwtTelemetryBroadcastHost, + UwtBroadcastLogApiProvider + ]; + + pay(): unknown[] { + const options: UwtScenarioOptions = { name: 'checkout', timeoutMs: 5000 }; + const scenario: UwtScenario = this.scenarios.start(options); + scenario.step('pay', { cart: 'c-1' }).endStep({ metadata: { ok: true } }); + scenario.aggregateStart('validate').aggregateEnd('validate'); + scenario.setData({ items: 2 }); + const detail: UwtScenarioStepDetail = {}; + scenario.failStep(new Error('retry'), detail); + const outcome: UwtScenarioOutcome = { statusCode: 200 }; + const traced: UwtTraceScenarioOptions = { + outcome: (value) => ({ metadata: { value } }), + cancelOutcome: outcome + }; + const settled: Observable = this.scenarios.settled$; + const found: UwtScenario[] = [ + ...this.scenarios.findByName('checkout'), + ...this.scenarios.getActive() + ]; + return [ + settled, + found, + this.scenarios.find('id'), + this.scenarios.findByNameAndId('checkout', 'id'), + of(1).pipe(traceScenario(scenario, traced)) + ]; + } + + track(): Promise { + const name: UwtBIEventName = 'export_clicked'; + const biDetail: UwtBIEventDetail = {}; + this.bi.track(name, { format: 'csv' }, biDetail); + const logDetail: UwtLogDetail = { error: new Error('x') }; + this.logger.warn('careful', logDetail); + return this.logs.query({ logger: 'x', limit: 1 }); + } +} + +/** The utilities an application or a sink calls. */ +export function utilities(): unknown[] { + const types: UwtTelemetryEventTypes = uwtEventTypes( + UWT_DEFAULT_EVENT_NAMESPACE + ); + const event: UwtTelemetrySinkEvent = uwtClassifyTelemetryEvent( + UWT_TELEMETRY_EVENT_TYPE.bi, + {} + ); + const flag: UwtDebugFlag = 'debugScenario'; + const redact: UwtRedactConfig = uwtRedactConfigFor( + UWT_DEFAULT_REDACT_CONFIG, + true + ); + const patterns: UwtPiiValuePattern[] = redact.scanValuePatterns; + uwtMarkSessionStart(uwtUuid()); + return [ + types, + event, + uwtIsDebugEnabled(flag), + uwtScrubString('a@b.c', redact), + uwtRedactMetadata({ email: 'a@b.c' }, redact), + uwtNormalizeError(new Error('x'), redact), + patterns, + UWT_REDACTED + ]; +} + +/** Every remaining type, as an integration names it. */ +export interface Everything { + attribution: UwtTelemetryAttribution; + biEvent: UwtBIEvent; + biNames: UwtBIEventNames; + logger: UwtLoggerName; + loggerNames: UwtLoggerNames; + aggregate: UwtScenarioAggregate; + scenario: UwtScenarioName; + scenarioNames: UwtScenarioNames; + status: UwtScenarioStatus; + step: UwtScenarioStep; + stepEvent: UwtScenarioStepEvent; + stepStatus: UwtScenarioStepStatus; + steps: UwtScenarioSteps; + stepName: UwtStepName; + error: UwtTelemetryError; +} diff --git a/projects/ngx-ui-watchtower/type-tests/empty-registry.ts b/projects/ngx-ui-watchtower/type-tests/empty-registry.ts new file mode 100644 index 0000000..b2a0838 --- /dev/null +++ b/projects/ngx-ui-watchtower/type-tests/empty-registry.ts @@ -0,0 +1,28 @@ +/** + * Nothing augmented: every name falls back to `string`, so an application + * that never declares a registry can use any name. + * + * Compiled by `npm run typecheck` with `compat.ts`, never run. It must not + * share a compilation with `augmented.ts`, whose augmentation would apply + * here too. + */ +import { + UwtBIEventName, + UwtLoggerName, + UwtScenarioName, + UwtStepName +} from '@absaoss-cps/ngx-ui-watchtower'; + +type Equals = + (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 + ? true + : false; + +function expectType(): T | void { + // Checked at compile time only. +} + +expectType>(); +expectType>(); +expectType>(); +expectType>(); diff --git a/projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json b/projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json new file mode 100644 index 0000000..87beb04 --- /dev/null +++ b/projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json @@ -0,0 +1,5 @@ +/* The registries augmented, kept apart from the empty-registry fallback. */ +{ + "extends": "./tsconfig.json", + "files": ["augmented.ts"] +} diff --git a/projects/ngx-ui-watchtower/type-tests/tsconfig.json b/projects/ngx-ui-watchtower/type-tests/tsconfig.json new file mode 100644 index 0000000..6180b30 --- /dev/null +++ b/projects/ngx-ui-watchtower/type-tests/tsconfig.json @@ -0,0 +1,14 @@ +/* + * Consumer type tests, compiled against the public entry points by the + * Angular compiler (`ngc`), so Angular's own checks apply as in a consumer + * build. `compat.ts` and `empty-registry.ts` share this compilation; the + * augmentation in `augmented.ts` would leak into them, so it has its own. + */ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types": [] + }, + "files": ["compat.ts", "empty-registry.ts"] +} diff --git a/scripts/api-snapshot.mjs b/scripts/api-snapshot.mjs new file mode 100644 index 0000000..2316d49 --- /dev/null +++ b/scripts/api-snapshot.mjs @@ -0,0 +1,226 @@ +/** + * Snapshots the public API of each built package: every export of every + * entry point, with its kind and its members and signatures as the + * TypeScript checker resolves them from the published declarations. How a + * type is built (inheritance, generics) doesn't show; what a consumer sees + * does. + * + * node scripts/api-snapshot.mjs compare with the committed snapshots + * node scripts/api-snapshot.mjs --update write them again + * + * Run after `npm run build`. Update only for an intended API change, and + * review the diff. + */ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +const PACKAGES = [ + { + dist: 'dist/ngx-ui-watchtower', + snapshot: 'projects/ngx-ui-watchtower/public-api.snapshot.json' + } +]; +const FORMAT = + ts.TypeFormatFlags.NoTruncation | + ts.TypeFormatFlags.UseSingleQuotesForStringLiteralType; + +/** Each entry point's import path and declaration file. */ +function entriesOf(dist) { + const manifest = JSON.parse( + readFileSync(join(ROOT, dist, 'package.json'), 'utf8') + ); + return Object.entries(manifest.exports) + .filter(([, target]) => target.types) + .map(([subpath, target]) => ({ + id: manifest.name + subpath.slice(1), + file: join(ROOT, dist, target.types) + })); +} + +const entries = PACKAGES.flatMap((pkg) => + entriesOf(pkg.dist).map((entry) => ({ ...entry, pkg })) +); +const program = ts.createProgram( + entries.map((entry) => entry.file), + { + noEmit: true, + strict: true, + skipLibCheck: true, + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + types: [], + // The packages' imports of each other resolve to what was built. + paths: Object.fromEntries(entries.map((e) => [e.id, [e.file]])) + } +); +const checker = program.getTypeChecker(); +const has = (symbol, flag) => (symbol.flags & flag) !== 0; +const text = (type) => checker.typeToString(type, undefined, FORMAT); + +function modifiers(symbol) { + const declaration = symbol.valueDeclaration ?? symbol.declarations?.[0]; + return declaration ? ts.getCombinedModifierFlags(declaration) : 0; +} + +/** ``, as declared. */ +function typeParameters(symbol) { + const declaration = symbol.declarations?.find((d) => d.typeParameters); + return declaration + ? `<${declaration.typeParameters.map((p) => p.getText()).join(', ')}>` + : ''; +} + +function signatures(type, kind, prefix) { + return checker + .getSignaturesOfType(type, kind) + .map((s) => prefix + checker.signatureToString(s, undefined, FORMAT)); +} + +/** The public and protected members of `type`, one line each, sorted. */ +function members(type, prefix = '') { + const lines = checker.getPropertiesOfType(type).flatMap((member) => { + const flags = modifiers(member); + if ( + flags & ts.ModifierFlags.Private || + member.name.startsWith('#') || + member.name.startsWith('__#') || + member.name === 'prototype' + ) { + return []; + } + const tags = + prefix + + (flags & ts.ModifierFlags.Protected ? 'protected ' : '') + + (flags & ts.ModifierFlags.Abstract ? 'abstract ' : '') + + (flags & ts.ModifierFlags.Readonly ? 'readonly ' : ''); + const optional = has(member, ts.SymbolFlags.Optional) ? '?' : ''; + const memberType = checker.getTypeOfSymbol(member); + if (has(member, ts.SymbolFlags.Method)) { + // An optional method's type includes `undefined`, which has none. + return signatures( + checker.getNonNullableType(memberType), + ts.SignatureKind.Call, + `${tags}${member.name}${optional}` + ); + } + const accessor = [ + has(member, ts.SymbolFlags.GetAccessor) ? 'get' : '', + has(member, ts.SymbolFlags.SetAccessor) ? 'set' : '' + ] + .filter(Boolean) + .join('/'); + return [ + `${tags}${accessor ? accessor + ' ' : ''}${member.name}${optional}: ${text(memberType)}` + ]; + }); + const indexes = checker + .getIndexInfosOfType(type) + .map( + (info) => + `${prefix}${info.isReadonly ? 'readonly ' : ''}[key: ${text(info.keyType)}]: ${text(info.type)}` + ); + return [...lines, ...indexes].sort(); +} + +/** How one export looks to a consumer: its kind, then its shape. */ +function describe(exported) { + const symbol = has(exported, ts.SymbolFlags.Alias) + ? checker.getAliasedSymbol(exported) + : exported; + const typeOnly = (exported.declarations ?? []).some( + (d) => + ts.isExportSpecifier(d) && (d.isTypeOnly || d.parent.parent.isTypeOnly) + ); + const only = + typeOnly && has(symbol, ts.SymbolFlags.Value) ? 'type-only ' : ''; + const generics = typeParameters(symbol); + if (has(symbol, ts.SymbolFlags.Class)) { + const abstract = modifiers(symbol) & ts.ModifierFlags.Abstract; + const statics = checker.getTypeOfSymbol(symbol); + return [ + `${only}${abstract ? 'abstract ' : ''}class${generics}`, + ...signatures(statics, ts.SignatureKind.Construct, 'new '), + ...members(statics, 'static '), + ...members(checker.getDeclaredTypeOfSymbol(symbol)) + ]; + } + if (has(symbol, ts.SymbolFlags.Interface)) { + const type = checker.getDeclaredTypeOfSymbol(symbol); + return [ + `${only}interface${generics}`, + ...signatures(type, ts.SignatureKind.Call, ''), + ...signatures(type, ts.SignatureKind.Construct, 'new '), + ...members(type) + ]; + } + if (has(symbol, ts.SymbolFlags.TypeAlias)) { + const type = checker.getDeclaredTypeOfSymbol(symbol); + return [ + `type${generics} = ${checker.typeToString(type, undefined, FORMAT | ts.TypeFormatFlags.InTypeAlias)}` + ]; + } + if (has(symbol, ts.SymbolFlags.Function)) { + return signatures( + checker.getTypeOfSymbol(symbol), + ts.SignatureKind.Call, + `${only}function` + ); + } + if (has(symbol, ts.SymbolFlags.Variable)) { + const readonly = + ts.getCombinedNodeFlags(symbol.valueDeclaration) & ts.NodeFlags.Const; + return [ + `${only}${readonly ? 'const' : 'let'}: ${text(checker.getTypeOfSymbol(symbol))}` + ]; + } + throw new Error(`Unexpected export kind: ${exported.name}`); +} + +function snapshotOf(entry) { + const source = program.getSourceFile(entry.file); + const module = source && checker.getSymbolAtLocation(source); + if (!module) { + throw new Error(`Cannot read ${entry.file}; run npm run build first`); + } + return Object.fromEntries( + checker + .getExportsOfModule(module) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((exported) => [exported.name, describe(exported)]) + ); +} + +const update = process.argv.includes('--update'); +let failed = false; +for (const pkg of PACKAGES) { + const actual = + JSON.stringify( + Object.fromEntries( + entries + .filter((entry) => entry.pkg === pkg) + .map((entry) => [entry.id, snapshotOf(entry)]) + ), + null, + 2 + ) + '\n'; + const file = join(ROOT, pkg.snapshot); + const expected = existsSync(file) ? readFileSync(file, 'utf8') : ''; + if (update) { + writeFileSync(file, actual); + console.log(`Wrote ${pkg.snapshot}`); + } else if (actual !== expected) { + failed = true; + writeFileSync(`${file}.actual`, actual); + console.error( + `${pkg.snapshot} differs from the build; see ${pkg.snapshot}.actual.\n` + + 'Run npm run api:update only if the API change is intended.' + ); + } else { + console.log(`${pkg.snapshot} matches the build`); + } +} +process.exitCode = failed ? 1 : 0; From b7f4eb2ad4cc05fb27b9d8a6e5b80807f9695038 Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 12 Nov 2026 20:32:36 +0100 Subject: [PATCH 2/3] address feedback --- README.md | 4 ++-- package.json | 4 ++-- .../public-api.snapshot.json | 20 ------------------- scripts/api-snapshot.mjs | 8 ++++++-- 4 files changed, 10 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index f8f6293..91ba9b6 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,8 @@ The package is built to `dist/ngx-ui-watchtower`. | `npm run test` | Runs the unit tests (Jest) | | `npm run test:coverage` | Runs the unit tests with coverage, as CI does | | `npm run typecheck` | Type-checks every library and spec tsconfig, and the type tests | -| `npm run api:check` | Compares the built public API with its committed snapshot | -| `npm run api:update` | Writes that snapshot again, for an intended API change | +| `npm run api:check` | Builds, then compares the public API with its snapshot | +| `npm run api:update` | Builds, then rewrites the snapshot, for an intended change | | `npm run lint` | ESLint, no warnings allowed | | `npm run format` | Formats with Prettier | | `npm run format:check` | Checks formatting without writing | diff --git a/package.json b/package.json index 076ed36..0bffd83 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "format": "prettier --write \"**/*.{ts,mjs,html}\"", "format:check": "prettier --check \"**/*.{ts,mjs,html}\"", "typecheck": "tsc -p projects/ngx-ui-watchtower/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/tsconfig.spec.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.lib.json --noEmit && tsc -p projects/ngx-ui-watchtower/rum/tsconfig.spec.json --noEmit && ngc -p projects/ngx-ui-watchtower/type-tests/tsconfig.json && ngc -p projects/ngx-ui-watchtower/type-tests/tsconfig.augmented.json", - "api:check": "node scripts/api-snapshot.mjs", - "api:update": "node scripts/api-snapshot.mjs --update" + "api:check": "npm run build && node scripts/api-snapshot.mjs", + "api:update": "npm run build && node scripts/api-snapshot.mjs --update" }, "private": true, "dependencies": { diff --git a/projects/ngx-ui-watchtower/public-api.snapshot.json b/projects/ngx-ui-watchtower/public-api.snapshot.json index b18d4f0..87fb6e9 100644 --- a/projects/ngx-ui-watchtower/public-api.snapshot.json +++ b/projects/ngx-ui-watchtower/public-api.snapshot.json @@ -91,15 +91,11 @@ "UwtBITelemetryService": [ "class", "new (): UwtBITelemetryService", - "static ɵfac: unknown", - "static ɵprov: ɵɵInjectableDeclaration", "track(eventName: string, metadata?: UwtTelemetryMetadata | undefined, detail?: UwtBIEventDetail | undefined): void" ], "UwtBroadcastLogApiProvider": [ "class", "new (): UwtBroadcastLogApiProvider", - "static ɵfac: unknown", - "static ɵprov: ɵɵInjectableDeclaration", "flush(): void", "ngOnDestroy(): void", "query(filter: UwtLogQuery): Promise", @@ -108,8 +104,6 @@ "UwtBroadcastTelemetrySink": [ "class", "new (): UwtBroadcastTelemetrySink", - "static ɵfac: unknown", - "static ɵprov: ɵɵInjectableDeclaration", "flush(beacon?: boolean | undefined): void", "getSessionId(): string | undefined", "getUserId(): string | undefined", @@ -165,8 +159,6 @@ "UwtLoggerService": [ "class", "new (): UwtLoggerService", - "static ɵfac: unknown", - "static ɵprov: ɵɵInjectableDeclaration", "getLogger(name: string): UwtLogger", "ngOnDestroy(): void", "query(filter?: UwtLogQuery | undefined): Promise" @@ -205,16 +197,12 @@ "UwtNoopLogApiProvider": [ "class", "new (): UwtNoopLogApiProvider", - "static ɵfac: unknown", - "static ɵprov: ɵɵInjectableDeclaration", "query(_filter: UwtLogQuery): Promise", "send(_record: UwtLogRecord): void" ], "UwtNoopTelemetrySink": [ "class", "new (): UwtNoopTelemetrySink", - "static ɵfac: unknown", - "static ɵprov: ɵɵInjectableDeclaration", "flush(_beacon?: boolean | undefined): void", "getSessionId(): string | undefined", "getUserId(): string | undefined", @@ -384,8 +372,6 @@ "UwtScenarioTelemetryService": [ "class", "new (): UwtScenarioTelemetryService", - "static ɵfac: unknown", - "static ɵprov: ɵɵInjectableDeclaration", "find(scenarioId: string): UwtScenario | undefined", "findByName(name: string): UwtScenario[]", "findByNameAndId(name: string, scenarioId: string): UwtScenario | undefined", @@ -409,8 +395,6 @@ "UwtTelemetryBroadcastHost": [ "class", "new (): UwtTelemetryBroadcastHost", - "static ɵfac: unknown", - "static ɵprov: ɵɵInjectableDeclaration", "get received: number", "ngOnDestroy(): void" ], @@ -450,8 +434,6 @@ "UwtTelemetrySink": [ "abstract class", "new (): UwtTelemetrySink", - "static ɵfac: unknown", - "static ɵprov: ɵɵInjectableDeclaration", "abstract flush(beacon?: boolean | undefined): void", "abstract getSessionId(): string | undefined", "abstract getUserId(): string | undefined", @@ -556,8 +538,6 @@ "UwtRumTelemetrySink": [ "class", "new (): UwtRumTelemetrySink", - "static ɵfac: unknown", - "static ɵprov: ɵɵInjectableDeclaration", "flush(beacon?: boolean | undefined): void", "getSessionId(): string | undefined", "getUserId(): string | undefined", diff --git a/scripts/api-snapshot.mjs b/scripts/api-snapshot.mjs index 2316d49..140fc26 100644 --- a/scripts/api-snapshot.mjs +++ b/scripts/api-snapshot.mjs @@ -8,8 +8,9 @@ * node scripts/api-snapshot.mjs compare with the committed snapshots * node scripts/api-snapshot.mjs --update write them again * - * Run after `npm run build`. Update only for an intended API change, and - * review the diff. + * Run through `npm run api:check` and `npm run api:update`, which build + * first, so a stale dist/ never decides the result. Update only for an + * intended API change, and review the diff. */ import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -84,10 +85,13 @@ function signatures(type, kind, prefix) { function members(type, prefix = '') { const lines = checker.getPropertiesOfType(type).flatMap((member) => { const flags = modifiers(member); + // `ɵ` members (ɵfac, ɵprov, ɵcmp…) are Angular's internals: not public + // API, and free to change between minor versions. if ( flags & ts.ModifierFlags.Private || member.name.startsWith('#') || member.name.startsWith('__#') || + member.name.startsWith('ɵ') || member.name === 'prototype' ) { return []; From 0c4ff53f9ce9decdd31db8756b6b0975db9276c9 Mon Sep 17 00:00:00 2001 From: fateeand Date: Thu, 12 Nov 2026 20:41:31 +0100 Subject: [PATCH 3/3] more to prev --- .github/workflows/checks.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 98f94c7..d0eadcb 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -33,10 +33,7 @@ jobs: - name: Install dependencies run: npm ci - - name: Build library - run: npm run build - - - name: Check the public API + - name: Build library and check the public API run: npm run api:check test: