From ba9f0b3b9a4c65f07fed5562315dd2203f968286 Mon Sep 17 00:00:00 2001 From: Malte Nottmeyer Date: Mon, 20 Apr 2026 12:46:07 +0200 Subject: [PATCH 1/2] feat(sr): add configurable font-family transform Add FontFamilyStrategy and FontFamilyTransformConfig to rewrite text wireframe font families in the processor isolate. Smart mode normalizes Flutter-specific names for web replay; default strategy is none to preserve prior behavior. Document in CHANGELOG. Made-with: Cursor --- .../analysis_options.yaml | 1 + .../lib/datadog_session_replay.dart | 69 ++++++ .../cupertino_checkbox_recorder.dart | 2 +- .../cupertino_radio_recorder.dart | 2 +- .../cupertino_switch_recorder.dart | 4 +- .../material_widgets/switch_recorder.dart | 4 +- .../recording_extensions.dart | 2 +- .../lib/src/capture/recorder.dart | 10 +- .../lib/src/datadog_session_replay.dart | 4 +- .../src/processor/font_family_transform.dart | 224 ++++++++++++++++++ .../lib/src/processor/processor.dart | 13 +- .../lib/src/processor/processor_worker.dart | 16 +- .../widgets/checkbox_recorder_test.dart | 2 +- .../capture/widgets/radio_recorder_test.dart | 2 +- .../capture/widgets/switch_recorder_test.dart | 2 +- .../test/datadog_session_replay_test.dart | 7 + .../processor/font_family_transform_test.dart | 220 +++++++++++++++++ .../test/processor/processor_worker_test.dart | 93 +++++++- 18 files changed, 647 insertions(+), 30 deletions(-) create mode 100644 packages/datadog_session_replay/lib/src/processor/font_family_transform.dart create mode 100644 packages/datadog_session_replay/test/processor/font_family_transform_test.dart diff --git a/packages/datadog_session_replay/analysis_options.yaml b/packages/datadog_session_replay/analysis_options.yaml index 166736470..3ed869644 100644 --- a/packages/datadog_session_replay/analysis_options.yaml +++ b/packages/datadog_session_replay/analysis_options.yaml @@ -11,6 +11,7 @@ analyzer: linter: rules: + directives_ordering: true prefer_single_quotes: true prefer_relative_imports: true unawaited_futures: true diff --git a/packages/datadog_session_replay/lib/datadog_session_replay.dart b/packages/datadog_session_replay/lib/datadog_session_replay.dart index 07b724ec4..bb9f31d0b 100644 --- a/packages/datadog_session_replay/lib/datadog_session_replay.dart +++ b/packages/datadog_session_replay/lib/datadog_session_replay.dart @@ -45,6 +45,67 @@ enum TouchPrivacyLevel { hide, } +/// Controls how captured `TextStyle.fontFamily` values are rewritten before +/// they are sent as `SRTextStyle.family` on text wireframes. +/// +/// Custom callbacks are intentionally not supported: Session Replay builds +/// wireframes in a background isolate, which cannot serialize Dart closures +/// — use [FontFamilyTransformConfig.rules] instead. +enum FontFamilyStrategy { + /// Preserves reasonable CSS-compatible family names while + /// cleaning up Flutter-specific artifacts: strips `packages//` + /// asset-prefix, drops Flutter / platform sentinels that are not + /// valid on the web (e.g. `CupertinoSystemText`, `.SF UI Text`), + /// splits EditableText comma-joined fallback lists, quotes names + /// with spaces, and always appends a generic CSS fallback + /// (`sans-serif`) when none is present so the replay player has a + /// guaranteed fallback. Yields the default replay font stack when + /// the captured family is empty or fully sentinel. + smart, + + /// Always emits the single hardcoded CSS stack + /// `-apple-system, BlinkMacSystemFont, Roboto, sans-serif`, + /// regardless of the captured family. Matches the native SDK + /// behavior and is the safest choice if you do not want any + /// Flutter font names leaving the device. + fallback, + + /// No transform is applied—the raw `TextStyle.fontFamily` (or + /// comma-joined fallback list from EditableText) captured by the + /// recorders is emitted verbatim on the wire. Intended for + /// debugging and backwards compatibility with the previous + /// behavior; not recommended for production because values like + /// `packages/google_fonts/Roboto` or `""` may not render correctly + /// in the replay player. + none, +} + +/// Serialized font-family rewriting rules passed to the processor isolate. +/// +/// Use [rules] for exact-match overrides only (no callbacks). +class FontFamilyTransformConfig { + /// Default is [FontFamilyStrategy.none] for backwards compatibility; set + /// [FontFamilyStrategy.smart] for web-friendly font stacks. + final FontFamilyStrategy strategy; + + /// Exact-match overrides, applied per comma-separated token before built-in + /// normalization when [strategy] is [FontFamilyStrategy.smart]. Keys are + /// case-sensitive—match either the captured token as recorded (trimmed / + /// outer quotes removed) or the same token after stripping a + /// `packages//` asset prefix. Values may be comma-separated stacks. + /// + /// Use an empty string key (`''`) in [rules] to supply a custom CSS stack + /// when the captured family is empty or becomes empty after dropping + /// sentinels; if absent, [FontFamilyStrategy.smart] uses the default iOS-parity + /// stack in those cases. + final Map rules; + + const FontFamilyTransformConfig({ + this.strategy = FontFamilyStrategy.none, + this.rules = const {}, + }); +} + /// Configuration options for Session Replay, including /// default privacy levels. class DatadogSessionReplayConfiguration { @@ -78,12 +139,20 @@ class DatadogSessionReplayConfiguration { String? customEndpoint; + /// Rewrites captured font family strings into web-compatible CSS stacks in + /// the processor isolate before snapshots are serialized. + /// + /// Defaults to [FontFamilyStrategy.none] so existing behavior is unchanged; + /// use [FontFamilyStrategy.smart] for web-friendly normalization. + FontFamilyTransformConfig fontFamilyTransform; + DatadogSessionReplayConfiguration({ required this.replaySampleRate, this.textAndInputPrivacyLevel = TextAndInputPrivacyLevel.maskAll, this.imagePrivacyLevel = ImagePrivacyLevel.maskAll, this.touchPrivacyLevel = TouchPrivacyLevel.hide, this.customEndpoint, + this.fontFamilyTransform = const FontFamilyTransformConfig(), }); } diff --git a/packages/datadog_session_replay/lib/src/capture/element_recorders/cupertino_widgets/cupertino_checkbox_recorder.dart b/packages/datadog_session_replay/lib/src/capture/element_recorders/cupertino_widgets/cupertino_checkbox_recorder.dart index a3014662e..0ef8bb290 100644 --- a/packages/datadog_session_replay/lib/src/capture/element_recorders/cupertino_widgets/cupertino_checkbox_recorder.dart +++ b/packages/datadog_session_replay/lib/src/capture/element_recorders/cupertino_widgets/cupertino_checkbox_recorder.dart @@ -4,10 +4,10 @@ import 'package:flutter/cupertino.dart'; -import '../material_widgets/checkbox_recorder.dart'; import '../../capture_node.dart'; import '../../recorder.dart'; import '../../view_tree_snapshot.dart'; +import '../material_widgets/checkbox_recorder.dart'; import '../recording_extensions.dart'; import 'cupertino_recording_extensions.dart'; diff --git a/packages/datadog_session_replay/lib/src/capture/element_recorders/cupertino_widgets/cupertino_radio_recorder.dart b/packages/datadog_session_replay/lib/src/capture/element_recorders/cupertino_widgets/cupertino_radio_recorder.dart index 00ece7ae7..60fefaff4 100644 --- a/packages/datadog_session_replay/lib/src/capture/element_recorders/cupertino_widgets/cupertino_radio_recorder.dart +++ b/packages/datadog_session_replay/lib/src/capture/element_recorders/cupertino_widgets/cupertino_radio_recorder.dart @@ -4,10 +4,10 @@ import 'package:flutter/cupertino.dart'; -import '../material_widgets/radio_recorder.dart'; import '../../capture_node.dart'; import '../../recorder.dart'; import '../../view_tree_snapshot.dart'; +import '../material_widgets/radio_recorder.dart'; import '../recording_extensions.dart'; import 'cupertino_recording_extensions.dart'; diff --git a/packages/datadog_session_replay/lib/src/capture/element_recorders/cupertino_widgets/cupertino_switch_recorder.dart b/packages/datadog_session_replay/lib/src/capture/element_recorders/cupertino_widgets/cupertino_switch_recorder.dart index 7d960137f..de7f8e720 100644 --- a/packages/datadog_session_replay/lib/src/capture/element_recorders/cupertino_widgets/cupertino_switch_recorder.dart +++ b/packages/datadog_session_replay/lib/src/capture/element_recorders/cupertino_widgets/cupertino_switch_recorder.dart @@ -4,11 +4,11 @@ import 'package:flutter/cupertino.dart'; -import '../material_widgets/radio_recorder.dart'; -import '../material_widgets/switch_recorder.dart'; import '../../capture_node.dart'; import '../../recorder.dart'; import '../../view_tree_snapshot.dart'; +import '../material_widgets/radio_recorder.dart'; +import '../material_widgets/switch_recorder.dart'; import '../recording_extensions.dart'; import 'cupertino_recording_extensions.dart'; diff --git a/packages/datadog_session_replay/lib/src/capture/element_recorders/material_widgets/switch_recorder.dart b/packages/datadog_session_replay/lib/src/capture/element_recorders/material_widgets/switch_recorder.dart index 9425f1c7e..7c9854c81 100644 --- a/packages/datadog_session_replay/lib/src/capture/element_recorders/material_widgets/switch_recorder.dart +++ b/packages/datadog_session_replay/lib/src/capture/element_recorders/material_widgets/switch_recorder.dart @@ -6,14 +6,14 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import '../cupertino_widgets/cupertino_recording_extensions.dart'; -import 'radio_recorder.dart'; import '../../../extensions.dart'; import '../../../sr_data_models.dart'; import '../../capture_node.dart'; import '../../recorder.dart'; import '../../view_tree_snapshot.dart'; +import '../cupertino_widgets/cupertino_recording_extensions.dart'; import '../recording_extensions.dart'; +import 'radio_recorder.dart'; /// Detects 'Switch' widgets and places a Switch icon /// on SessionReplay. diff --git a/packages/datadog_session_replay/lib/src/capture/element_recorders/recording_extensions.dart b/packages/datadog_session_replay/lib/src/capture/element_recorders/recording_extensions.dart index 6fd42963d..90befac1f 100644 --- a/packages/datadog_session_replay/lib/src/capture/element_recorders/recording_extensions.dart +++ b/packages/datadog_session_replay/lib/src/capture/element_recorders/recording_extensions.dart @@ -4,9 +4,9 @@ import 'package:flutter/widgets.dart'; +import '../../../datadog_session_replay.dart'; import '../../sr_data_models.dart'; import '../recorder.dart'; -import '../../../datadog_session_replay.dart'; extension SRTextAlignment on TextAlign { SRHorizontalAlignment getSrHorizontalAlignment(TextDirection? textDirection) { diff --git a/packages/datadog_session_replay/lib/src/capture/recorder.dart b/packages/datadog_session_replay/lib/src/capture/recorder.dart index ed8893d90..cc13d7ce8 100644 --- a/packages/datadog_session_replay/lib/src/capture/recorder.dart +++ b/packages/datadog_session_replay/lib/src/capture/recorder.dart @@ -14,17 +14,17 @@ import '../rum_context.dart'; import '../widgets.dart'; import 'capture_node.dart'; import 'element_recorders/container_recorder.dart'; +import 'element_recorders/cupertino_widgets/cupertino_checkbox_recorder.dart'; +import 'element_recorders/cupertino_widgets/cupertino_radio_recorder.dart'; +import 'element_recorders/cupertino_widgets/cupertino_switch_recorder.dart'; import 'element_recorders/custom_paint_recorder.dart'; import 'element_recorders/editable_text_recorder.dart'; import 'element_recorders/image_recorder.dart'; -import 'element_recorders/privacy_recorder.dart'; -import 'element_recorders/text_recorder.dart'; import 'element_recorders/material_widgets/checkbox_recorder.dart'; -import 'element_recorders/cupertino_widgets/cupertino_checkbox_recorder.dart'; import 'element_recorders/material_widgets/radio_recorder.dart'; -import 'element_recorders/cupertino_widgets/cupertino_radio_recorder.dart'; import 'element_recorders/material_widgets/switch_recorder.dart'; -import 'element_recorders/cupertino_widgets/cupertino_switch_recorder.dart'; +import 'element_recorders/privacy_recorder.dart'; +import 'element_recorders/text_recorder.dart'; import 'pointer_capture.dart'; import 'view_tree_snapshot.dart'; diff --git a/packages/datadog_session_replay/lib/src/datadog_session_replay.dart b/packages/datadog_session_replay/lib/src/datadog_session_replay.dart index d1cd791d8..0837ed24f 100644 --- a/packages/datadog_session_replay/lib/src/datadog_session_replay.dart +++ b/packages/datadog_session_replay/lib/src/datadog_session_replay.dart @@ -80,7 +80,9 @@ class DatadogSessionReplay { }); if (success) { - await _processor.start(); + await _processor.start( + fontFamilyTransform: _configuration.fontFamilyTransform, + ); _startPeriodicCapture(); WidgetsBinding.instance.addPostFrameCallback((_) { diff --git a/packages/datadog_session_replay/lib/src/processor/font_family_transform.dart b/packages/datadog_session_replay/lib/src/processor/font_family_transform.dart new file mode 100644 index 000000000..6dfcce89f --- /dev/null +++ b/packages/datadog_session_replay/lib/src/processor/font_family_transform.dart @@ -0,0 +1,224 @@ +// Unless otherwise stated, all files in this repository are licensed under +// the Apache License Version 2.0. This product includes software developed +// at Datadog (https://www.datadoghq.com/). Copyright 2025-Present Datadog, Inc. + +import 'package:meta/meta.dart'; + +import '../../datadog_session_replay.dart' + show FontFamilyStrategy, FontFamilyTransformConfig; +import '../sr_data_models.dart'; + +/// Default CSS font stack for the replay player, aligned with both native SDKs +/// (iOS: `-apple-system, BlinkMacSystemFont, 'Roboto', sans-serif`; +/// Android: `Roboto, sans-serif`). +/// Uses `Roboto` instead of `'Roboto'` so smart-mode round-trip matches +/// [formatFamilyForCssList]. +@visibleForTesting +const String defaultReplayFontStack = + '-apple-system, BlinkMacSystemFont, Roboto, sans-serif'; + +/// Resolved font-family strings Flutter uses for the platform UI font that are +/// not valid CSS `font-family` tokens in the replay player. Tokens matching +/// these are dropped in [FontFamilyStrategy.smart] so only web-usable names +/// remain (or the iOS-parity stack when nothing is left). +const Set _flutterFontSentinels = { + 'CupertinoSystemText', + 'CupertinoSystemDisplay', + '.SF UI Text', + '.SF UI Display', + '.AppleSystemUIFont', +}; + +/// Standard and webview font keywords (lowercased) that count as a generic +/// CSS fallback and block appending an extra [sans-serif](lowercase). +const Set _genericFamilyLower = { + 'serif', + 'sans-serif', + 'monospace', + 'system-ui', + 'cursive', + 'fantasy', + '-apple-system', + 'blinkmacsystemfont', +}; + +/// Strips a `packages//` prefix from pub/bundled font family +/// names. +@visibleForTesting +String stripPackageFontPrefix(String name) { + if (name.isEmpty) { + return name; + } + const prefix = 'packages/'; + if (!name.startsWith(prefix)) { + return name; + } + final firstSlash = name.indexOf('/', prefix.length); + if (firstSlash < 0) { + return name; + } + return name.substring(firstSlash + 1); +} + +@visibleForTesting +String peelOuterQuotes(String s) { + var t = s.trim(); + while (t.length >= 2) { + final a = t.codeUnitAt(0); + final b = t.codeUnitAt(t.length - 1); + if (a == 0x27 && b == 0x27) { + t = t.substring(1, t.length - 1).trim(); + } else if (a == 0x22 && b == 0x22) { + t = t.substring(1, t.length - 1).trim(); + } else { + break; + } + } + return t; +} + +@visibleForTesting +String formatFamilyForCssList(String name) { + if (name.isEmpty) { + return name; + } + final lower = name.toLowerCase(); + if (_genericFamilyLower.contains(lower)) { + return switch (lower) { + 'blinkmacsystemfont' => 'BlinkMacSystemFont', + '-apple-system' => '-apple-system', + _ => lower, + }; + } + if (name.contains(' ')) { + return "'${name.replaceAll("'", r"\'")}'"; + } + return name; +} + +bool _hasGenericFamily(List tokens) { + for (final t in tokens) { + if (_genericFamilyLower.contains(peelOuterQuotes(t).toLowerCase())) { + return true; + } + } + return false; +} + +/// Comma-split family tokens after trim and outer-quote peel; used for rule +/// values and parsing [FontFamilyTransformConfig.rules] empty-key fallback. +List _familyTokensFromCommaList(String source) { + final out = []; + for (final part in source.split(',')) { + final p = peelOuterQuotes(part); + if (p.isNotEmpty) { + out.add(p); + } + } + return out; +} + +/// Rewrites captured Flutter font family strings for web replay. +class FontFamilyTransform { + FontFamilyTransform(this.config); + + final FontFamilyTransformConfig config; + + String transformFamily(String captured) { + switch (config.strategy) { + case FontFamilyStrategy.none: + return captured; + case FontFamilyStrategy.fallback: + return defaultReplayFontStack; + case FontFamilyStrategy.smart: + return _applySmart(captured); + } + } + + SRTextWireframe apply(SRTextWireframe wireframe) { + final nextFamily = transformFamily(wireframe.textStyle.family); + if (nextFamily == wireframe.textStyle.family) { + return wireframe; + } + return SRTextWireframe( + id: wireframe.id, + x: wireframe.x, + y: wireframe.y, + width: wireframe.width, + height: wireframe.height, + text: wireframe.text, + textStyle: SRTextStyle( + color: wireframe.textStyle.color, + family: nextFamily, + size: wireframe.textStyle.size, + ), + border: wireframe.border, + clip: wireframe.clip, + shapeStyle: wireframe.shapeStyle, + textPosition: wireframe.textPosition, + ); + } + + String _applySmart(String captured) { + if (captured.trim().isEmpty) { + return _emptyFamilyFallback(); + } + + final rawTokens = captured.split(','); + final expanded = []; + + for (final raw in rawTokens) { + final peeled = peelOuterQuotes(raw); + if (peeled.isEmpty) { + continue; + } + + final stripped = stripPackageFontPrefix(peeled); + if (stripped.isEmpty) { + continue; + } + + final replacement = config.rules[peeled] ?? config.rules[stripped]; + if (replacement != null) { + expanded.addAll(_familyTokensFromCommaList(replacement)); + continue; + } + + if (_flutterFontSentinels.contains(stripped)) { + continue; + } + + expanded.add(stripped); + } + + if (expanded.isEmpty) { + return _emptyFamilyFallback(); + } + + return _joinFormattedFamilyStack(expanded); + } + + /// Appends a generic keyword when needed and formats for CSS `font-family`. + String _joinFormattedFamilyStack(List expanded) { + var working = List.from(expanded); + if (!_hasGenericFamily(working)) { + working.add('sans-serif'); + } + return working.map(formatFamilyForCssList).join(', '); + } + + /// When the captured family is empty or no usable tokens remain, honor the + /// [FontFamilyTransformConfig.rules] entry whose key is the empty string if + /// set; otherwise the iOS-parity stack. + String _emptyFamilyFallback() { + final custom = config.rules['']; + if (custom != null && custom.trim().isNotEmpty) { + final expanded = _familyTokensFromCommaList(custom); + if (expanded.isEmpty) { + return defaultReplayFontStack; + } + return _joinFormattedFamilyStack(expanded); + } + return defaultReplayFontStack; + } +} diff --git a/packages/datadog_session_replay/lib/src/processor/processor.dart b/packages/datadog_session_replay/lib/src/processor/processor.dart index 36c07e7bd..5fd386ee8 100644 --- a/packages/datadog_session_replay/lib/src/processor/processor.dart +++ b/packages/datadog_session_replay/lib/src/processor/processor.dart @@ -7,6 +7,7 @@ import 'dart:isolate'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +import '../../datadog_session_replay.dart'; import '../capture/recorder.dart'; import '../datadog_session_replay_init_stub.dart' if (dart.library.io) '../datadog_session_replay_init_mobile.dart'; @@ -21,7 +22,10 @@ class SessionReplayProcessor with WidgetsBindingObserver { SendPort? _mainSendPort; Isolate? _processorIsolate; - Future start() async { + Future start({ + FontFamilyTransformConfig fontFamilyTransform = + const FontFamilyTransformConfig(), + }) async { WidgetsBinding.instance.addObserver(this); _processorIsolate = await Isolate.spawn( _captureProcessor, @@ -29,6 +33,7 @@ class SessionReplayProcessor with WidgetsBindingObserver { RootIsolateToken.instance!, DatadogSessionReplayPlatform.instance.isolateToken, _mainReceivePort.sendPort, + fontFamilyTransform, ), ); @@ -54,7 +59,9 @@ class SessionReplayProcessor with WidgetsBindingObserver { final responsePort = args.sendPort; responsePort.send(commandPort.sendPort); - final internalProcessor = ProcessorWorker(); + final internalProcessor = ProcessorWorker( + fontFamilyTransform: args.fontFamilyTransform, + ); await for (final message in commandPort) { if (message is CaptureResult) { @@ -74,10 +81,12 @@ class _ProcessorArgs { final RootIsolateToken rootIsolateToken; final Object? platformIsolateToken; final SendPort sendPort; + final FontFamilyTransformConfig fontFamilyTransform; const _ProcessorArgs( this.rootIsolateToken, this.platformIsolateToken, this.sendPort, + this.fontFamilyTransform, ); } diff --git a/packages/datadog_session_replay/lib/src/processor/processor_worker.dart b/packages/datadog_session_replay/lib/src/processor/processor_worker.dart index 48e92d7fc..8a2c91a2a 100644 --- a/packages/datadog_session_replay/lib/src/processor/processor_worker.dart +++ b/packages/datadog_session_replay/lib/src/processor/processor_worker.dart @@ -7,17 +7,26 @@ import 'dart:convert'; import 'package:meta/meta.dart'; +import '../../datadog_session_replay.dart'; import '../capture/pointer_capture.dart'; import '../capture/recorder.dart'; import '../capture/view_tree_snapshot.dart'; import '../datadog_session_replay_platform_interface.dart'; import '../sr_data_models.dart'; import 'diff.dart'; +import 'font_family_transform.dart'; /// An internal class that does all of the work for processing a ViewSnapshot into SRRecords, /// including creating diffs for non full records. When complete, the processor sends /// the records to the native method channel so they can be serialized and sent to intake. class ProcessorWorker { + ProcessorWorker({ + FontFamilyTransformConfig fontFamilyTransform = + const FontFamilyTransformConfig(), + }) : _fontTransform = FontFamilyTransform(fontFamilyTransform); + + final FontFamilyTransform _fontTransform; + ViewTreeSnapshot? _lastSnapshot; List? _lastWireframes; final Map _recordCountByViewId = {}; @@ -118,7 +127,12 @@ class ProcessorWorker { List generateWireframes(CaptureResult result) { return result.viewTreeSnapshot.nodes .expand((element) => element.buildWireframes()) - .toList(); + .map((wireframe) { + if (wireframe is SRTextWireframe) { + return _fontTransform.apply(wireframe); + } + return wireframe; + }).toList(); } SRRecord _createIncrementalPointerRecord(PointerCapture pointer) { diff --git a/packages/datadog_session_replay/test/capture/widgets/checkbox_recorder_test.dart b/packages/datadog_session_replay/test/capture/widgets/checkbox_recorder_test.dart index 71aa38a08..7226d76f6 100644 --- a/packages/datadog_session_replay/test/capture/widgets/checkbox_recorder_test.dart +++ b/packages/datadog_session_replay/test/capture/widgets/checkbox_recorder_test.dart @@ -8,9 +8,9 @@ import 'package:datadog_session_replay/src/capture/capture_node.dart'; import 'package:datadog_session_replay/src/capture/element_recorders/cupertino_widgets/cupertino_checkbox_recorder.dart'; import 'package:datadog_session_replay/src/capture/element_recorders/material_widgets/checkbox_recorder.dart'; import 'package:datadog_session_replay/src/capture/recorder.dart'; +import 'package:datadog_session_replay/src/extensions.dart'; import 'package:datadog_session_replay/src/rum_context.dart'; import 'package:datadog_session_replay/src/sr_data_models.dart'; -import 'package:datadog_session_replay/src/extensions.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/packages/datadog_session_replay/test/capture/widgets/radio_recorder_test.dart b/packages/datadog_session_replay/test/capture/widgets/radio_recorder_test.dart index 41e232b36..61da1b3a6 100644 --- a/packages/datadog_session_replay/test/capture/widgets/radio_recorder_test.dart +++ b/packages/datadog_session_replay/test/capture/widgets/radio_recorder_test.dart @@ -8,9 +8,9 @@ import 'package:datadog_session_replay/src/capture/capture_node.dart'; import 'package:datadog_session_replay/src/capture/element_recorders/cupertino_widgets/cupertino_radio_recorder.dart'; import 'package:datadog_session_replay/src/capture/element_recorders/material_widgets/radio_recorder.dart'; import 'package:datadog_session_replay/src/capture/recorder.dart'; +import 'package:datadog_session_replay/src/extensions.dart'; import 'package:datadog_session_replay/src/rum_context.dart'; import 'package:datadog_session_replay/src/sr_data_models.dart'; -import 'package:datadog_session_replay/src/extensions.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/packages/datadog_session_replay/test/capture/widgets/switch_recorder_test.dart b/packages/datadog_session_replay/test/capture/widgets/switch_recorder_test.dart index 36093ddb1..b690c1081 100644 --- a/packages/datadog_session_replay/test/capture/widgets/switch_recorder_test.dart +++ b/packages/datadog_session_replay/test/capture/widgets/switch_recorder_test.dart @@ -8,9 +8,9 @@ import 'package:datadog_session_replay/src/capture/capture_node.dart'; import 'package:datadog_session_replay/src/capture/element_recorders/cupertino_widgets/cupertino_switch_recorder.dart'; import 'package:datadog_session_replay/src/capture/element_recorders/material_widgets/switch_recorder.dart'; import 'package:datadog_session_replay/src/capture/recorder.dart'; +import 'package:datadog_session_replay/src/extensions.dart'; import 'package:datadog_session_replay/src/rum_context.dart'; import 'package:datadog_session_replay/src/sr_data_models.dart'; -import 'package:datadog_session_replay/src/extensions.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; diff --git a/packages/datadog_session_replay/test/datadog_session_replay_test.dart b/packages/datadog_session_replay/test/datadog_session_replay_test.dart index d558aec52..5b91c18aa 100644 --- a/packages/datadog_session_replay/test/datadog_session_replay_test.dart +++ b/packages/datadog_session_replay/test/datadog_session_replay_test.dart @@ -24,6 +24,13 @@ void main() { expect(initialPlatform, isInstanceOf()); }); + test('DatadogSessionReplayConfiguration default fontFamilyTransform is none', + () { + final c = DatadogSessionReplayConfiguration(replaySampleRate: 100.0); + expect(c.fontFamilyTransform.strategy, FontFamilyStrategy.none); + expect(c.fontFamilyTransform.rules, isEmpty); + }); + group('DatadogSessionReplay', () { final mockPlatform = MockDatadogSessionReplayPlatform(); final mockInternalLogger = MockInternalLogger(); diff --git a/packages/datadog_session_replay/test/processor/font_family_transform_test.dart b/packages/datadog_session_replay/test/processor/font_family_transform_test.dart new file mode 100644 index 000000000..d39c47c0d --- /dev/null +++ b/packages/datadog_session_replay/test/processor/font_family_transform_test.dart @@ -0,0 +1,220 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025-Present Datadog, Inc. + +import 'package:datadog_session_replay/datadog_session_replay.dart'; +import 'package:datadog_session_replay/src/processor/font_family_transform.dart'; +import 'package:datadog_session_replay/src/sr_data_models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('stripPackageFontPrefix', () { + test('strips packages// prefix', () { + expect( + stripPackageFontPrefix('packages/google_fonts/Roboto'), + 'Roboto', + ); + expect(stripPackageFontPrefix('Roboto'), 'Roboto'); + expect(stripPackageFontPrefix('packages/foo/bar/Baz'), 'bar/Baz'); + }); + }); + + group('FontFamilyTransform', () { + const iosStack = defaultReplayFontStack; + const smartConfig = FontFamilyTransformConfig( + strategy: FontFamilyStrategy.smart, + ); + + test('none returns input unchanged', () { + final t = FontFamilyTransform( + const FontFamilyTransformConfig(strategy: FontFamilyStrategy.none), + ); + const raw = 'packages/google_fonts/Roboto'; + expect(t.transformFamily(raw), raw); + }); + + test('fallback always returns iOS-parity stack', () { + final t = FontFamilyTransform( + const FontFamilyTransformConfig(strategy: FontFamilyStrategy.fallback), + ); + expect(t.transformFamily(''), iosStack); + expect(t.transformFamily('Anything'), iosStack); + }); + + test('smart: empty becomes iOS-parity stack', () { + final t = FontFamilyTransform(smartConfig); + expect(t.transformFamily(''), iosStack); + expect(t.transformFamily(' '), iosStack); + }); + + test('smart: rules empty key overrides fallback for empty captured', () { + final t = FontFamilyTransform( + FontFamilyTransformConfig( + strategy: FontFamilyStrategy.smart, + rules: {'': 'Georgia, serif'}, + ), + ); + expect(t.transformFamily(''), 'Georgia, serif'); + expect(t.transformFamily(' '), 'Georgia, serif'); + }); + + test('smart: rules empty key applies when only sentinels remain', () { + final t = FontFamilyTransform( + FontFamilyTransformConfig( + strategy: FontFamilyStrategy.smart, + rules: {'': 'Verdana, sans-serif'}, + ), + ); + expect(t.transformFamily('CupertinoSystemText'), 'Verdana, sans-serif'); + }); + + test('smart: rules empty key whitespace-only value uses default stack', () { + final t = FontFamilyTransform( + FontFamilyTransformConfig( + strategy: FontFamilyStrategy.smart, + rules: {'': ' '}, + ), + ); + expect(t.transformFamily(''), iosStack); + }); + + test('smart: only sentinels become iOS-parity stack', () { + final t = FontFamilyTransform(smartConfig); + expect(t.transformFamily('CupertinoSystemText'), iosStack); + expect(t.transformFamily('.SF UI Text'), iosStack); + }); + + test('smart: packages prefix stripped and sans-serif appended', () { + final t = FontFamilyTransform(smartConfig); + expect( + t.transformFamily('packages/google_fonts/Roboto'), + 'Roboto, sans-serif', + ); + }); + + test('smart: comma-separated list preserves order and drops sentinel', () { + final t = FontFamilyTransform(smartConfig); + expect( + t.transformFamily('Roboto, CupertinoSystemText'), + 'Roboto, sans-serif', + ); + }); + + test('smart: quotes spaces in family names', () { + final t = FontFamilyTransform(smartConfig); + expect( + t.transformFamily('Open Sans'), + "'Open Sans', sans-serif", + ); + }); + + test('smart: rules override before sentinel strip', () { + final t = FontFamilyTransform( + FontFamilyTransformConfig( + strategy: FontFamilyStrategy.smart, + rules: { + 'CupertinoSystemText': 'Inter, sans-serif', + }, + ), + ); + expect(t.transformFamily('CupertinoSystemText'), 'Inter, sans-serif'); + }); + + test('smart: rules match stripped packages key', () { + final t = FontFamilyTransform( + FontFamilyTransformConfig( + strategy: FontFamilyStrategy.smart, + rules: { + 'Roboto': 'Lato, sans-serif', + }, + ), + ); + expect( + t.transformFamily('packages/google_fonts/Roboto'), + 'Lato, sans-serif', + ); + }); + + test('smart: rules prefer peeled key over stripped when both exist', () { + final t = FontFamilyTransform( + FontFamilyTransformConfig( + strategy: FontFamilyStrategy.smart, + rules: { + 'packages/foo/Roboto': 'FromPeeled', + 'Roboto': 'FromStripped', + }, + ), + ); + expect(t.transformFamily('packages/foo/Roboto'), 'FromPeeled, sans-serif'); + }); + + test('smart: does not append sans-serif when already generic', () { + final t = FontFamilyTransform(smartConfig); + expect(t.transformFamily('serif'), 'serif'); + expect(t.transformFamily('monospace'), 'monospace'); + expect(t.transformFamily('Roboto, sans-serif'), 'Roboto, sans-serif'); + }); + + test('smart: idempotent on iOS-parity stack', () { + final t = FontFamilyTransform(smartConfig); + final once = t.transformFamily(''); + expect(once, iosStack); + final twice = t.transformFamily(once); + expect(twice, once); + }); + + test('smart: idempotent on typical transformed output', () { + final t = FontFamilyTransform(smartConfig); + final once = t.transformFamily('Roboto'); + final twice = t.transformFamily(once); + expect(once, 'Roboto, sans-serif'); + expect(twice, once); + }); + + test('apply updates SRTextWireframe textStyle.family', () { + final t = FontFamilyTransform(smartConfig); + final w = SRTextWireframe( + id: 1, + x: 0, + y: 0, + width: 10, + height: 10, + text: 'hi', + textStyle: SRTextStyle( + color: '#FF0000FF', + family: '', + size: 12, + ), + ); + final out = t.apply(w); + expect(out.textStyle.family, iosStack); + expect(identical(out, w), isFalse); + }); + + test('apply returns same instance when none strategy', () { + final t = FontFamilyTransform( + const FontFamilyTransformConfig(strategy: FontFamilyStrategy.none), + ); + final w = SRTextWireframe( + id: 1, + x: 0, + y: 0, + width: 10, + height: 10, + text: 'hi', + textStyle: SRTextStyle( + color: '#FF0000FF', + family: 'packages/x/Y', + size: 12, + ), + ); + final out = t.apply(w); + expect(identical(out, w), isTrue); + }); + + test('smart: re-applying iOS-parity stack is idempotent string', () { + final t = FontFamilyTransform(smartConfig); + expect(t.transformFamily(defaultReplayFontStack), defaultReplayFontStack); + }); + }); +} diff --git a/packages/datadog_session_replay/test/processor/processor_worker_test.dart b/packages/datadog_session_replay/test/processor/processor_worker_test.dart index 808ce1ab5..588748ee2 100644 --- a/packages/datadog_session_replay/test/processor/processor_worker_test.dart +++ b/packages/datadog_session_replay/test/processor/processor_worker_test.dart @@ -5,12 +5,14 @@ import 'dart:convert'; import 'package:datadog_common_test/datadog_common_test.dart'; +import 'package:datadog_session_replay/datadog_session_replay.dart'; import 'package:datadog_session_replay/src/capture/capture_node.dart'; import 'package:datadog_session_replay/src/capture/pointer_capture.dart'; import 'package:datadog_session_replay/src/capture/recorder.dart'; import 'package:datadog_session_replay/src/capture/view_tree_snapshot.dart'; import 'package:datadog_session_replay/src/datadog_session_replay_platform_interface.dart'; import 'package:datadog_session_replay/src/extensions.dart'; +import 'package:datadog_session_replay/src/processor/font_family_transform.dart'; import 'package:datadog_session_replay/src/processor/processor_worker.dart'; import 'package:datadog_session_replay/src/rum_context.dart'; import 'package:datadog_session_replay/src/sr_data_models.dart'; @@ -27,6 +29,20 @@ class MockDatadogSessionReplayPlatform extends Mock class MockCaptureNode extends Mock implements CaptureNode {} +SRShapeWireframe createMockShapeWireframe(int id) { + return SRShapeWireframe( + id: id, + x: randomInt(), + y: randomInt(), + width: randomInt(), + height: randomInt(), + shapeStyle: SRShapeStyle( + cornerRadius: randomDouble(min: 0.0, max: 5.0), + backgroundColor: randomColor().toHexString(), + ), + ); +} + void main() { late MockDatadogSessionReplayPlatform mockPlatform; @@ -72,19 +88,74 @@ void main() { verifyNoMoreInteractions(mockPlatform); }); - SRShapeWireframe createMockShapeWireframe(int id) { - return SRShapeWireframe( - id: id, - x: randomInt(), - y: randomInt(), - width: randomInt(), - height: randomInt(), - shapeStyle: SRShapeStyle( - cornerRadius: randomDouble(min: 0.0, max: 5.0), - backgroundColor: randomColor().toHexString(), + test('generateWireframes maps text wireframe font family via smart transform', + () { + final mockCapture = MockCaptureNode(); + final original = SRTextWireframe( + id: 5, + x: 0, + y: 0, + width: 100, + height: 20, + text: 'hello', + textStyle: SRTextStyle( + color: '#FF000000', + family: '', + size: 14, ), ); - } + when(() => mockCapture.buildWireframes()).thenReturn([original]); + + final worker = ProcessorWorker( + fontFamilyTransform: const FontFamilyTransformConfig( + strategy: FontFamilyStrategy.smart, + ), + ); + final capture = CaptureResult( + ViewTreeSnapshot( + date: DateTime.now(), + context: RUMContext( + applicationId: randomString(), + sessionId: randomString(), + viewId: randomString(), + ), + viewportSize: Size(600, 800), + nodes: [mockCapture], + ), + null, + ); + + final wireframes = worker.generateWireframes(capture); + expect(wireframes.length, 1); + final text = wireframes.single as SRTextWireframe; + expect(text.textStyle.family, defaultReplayFontStack); + expect(identical(text, original), isFalse); + }); + + test('generateWireframes leaves non-text wireframes unchanged', () { + final mockCapture = MockCaptureNode(); + final shape = createMockShapeWireframe(0); + when(() => mockCapture.buildWireframes()).thenReturn([shape]); + + final worker = + ProcessorWorker(fontFamilyTransform: const FontFamilyTransformConfig()); + final capture = CaptureResult( + ViewTreeSnapshot( + date: DateTime.now(), + context: RUMContext( + applicationId: randomString(), + sessionId: randomString(), + viewId: randomString(), + ), + viewportSize: Size(600, 800), + nodes: [mockCapture], + ), + null, + ); + + final wireframes = worker.generateWireframes(capture); + expect(wireframes.single, shape); + }); test('processSnapshot for first snapshot generates full record', () async { // Given From 1479369bd6e898387cad0f30f326579e17173376 Mon Sep 17 00:00:00 2001 From: Malte Nottmeyer Date: Thu, 7 May 2026 18:07:05 +0200 Subject: [PATCH 2/2] chore(sr): dart format font_family_transform_test for CI Co-authored-by: Cursor --- .../test/processor/font_family_transform_test.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/datadog_session_replay/test/processor/font_family_transform_test.dart b/packages/datadog_session_replay/test/processor/font_family_transform_test.dart index d39c47c0d..41f65fcfb 100644 --- a/packages/datadog_session_replay/test/processor/font_family_transform_test.dart +++ b/packages/datadog_session_replay/test/processor/font_family_transform_test.dart @@ -145,7 +145,8 @@ void main() { }, ), ); - expect(t.transformFamily('packages/foo/Roboto'), 'FromPeeled, sans-serif'); + expect( + t.transformFamily('packages/foo/Roboto'), 'FromPeeled, sans-serif'); }); test('smart: does not append sans-serif when already generic', () {