diff --git a/.changeset/canvas-masking-web.md b/.changeset/canvas-masking-web.md new file mode 100644 index 00000000..8242424c --- /dev/null +++ b/.changeset/canvas-masking-web.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": minor +--- + +Add session replay canvas masking on Flutter web: `maskAllTexts`, `maskAllImages`, `PostHogMaskWidget`, and obscured text fields now apply to the CanvasKit canvas — enable by declaring `session_recording.canvasCapture.maskRegionsFn` in `posthog.init`, or just by mounting a `PostHogMaskWidget` (requires posthog-js 1.408.0+) diff --git a/.changeset/mask-text-flag-fix.md b/.changeset/mask-text-flag-fix.md new file mode 100644 index 00000000..1a87bd8b --- /dev/null +++ b/.changeset/mask-text-flag-fix.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": patch +--- + +Fix `maskAllTexts: false` still masking `Text` widgets when `maskAllImages` is enabled diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a7c28a8a..1f1def9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -167,7 +167,7 @@ jobs: - name: Test (web) if: needs.detect-markdown-only.outputs.markdown_only != 'true' working-directory: ./posthog_flutter - run: flutter test --platform chrome test/posthog_flutter_web_handler_test.dart test/posthog_widget_web_test.dart + run: flutter test --platform chrome test/posthog_flutter_web_handler_test.dart test/posthog_widget_web_test.dart test/web_canvas_mask_provider_test.dart # dart2js resolves the isolate-handler conditional import differently; # only a wasm compile exercises the dart2wasm selection this test guards. diff --git a/example/lib/masking_tests_screen.dart b/example/lib/masking_tests_screen.dart index dd1e3ffa..12a50855 100644 --- a/example/lib/masking_tests_screen.dart +++ b/example/lib/masking_tests_screen.dart @@ -256,7 +256,7 @@ class _MaskingTestsScreenState extends State { // The amber box matches no masking rule on its own, so it is // covered only if the wrapper contributes its own rect. _buildTestSection( - 'Test 15: PostHogMaskWidget with multiple children (needs maskAllTexts or maskAllImages)', + 'Test 15: PostHogMaskWidget with multiple children', PostHogMaskWidget( child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index e62f72b1..33cc43d9 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -14,6 +14,7 @@ import 'src/logs/posthog_log_severity.dart'; import 'src/posthog_config.dart'; import 'src/posthog_flutter_platform_interface.dart'; import 'src/posthog_flutter_web_handler.dart'; +import 'src/replay/web/web_canvas_mask_provider.dart'; import 'src/utils/capture_utils.dart'; /// A web implementation of the PosthogFlutterPlatform of the PosthogFlutter plugin. @@ -68,6 +69,8 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { final ph = posthog; _config = config; + WebCanvasMaskProvider(config).register(); + if (config.onFeatureFlags != null && ph != null) { final dartCallback = config.onFeatureFlags!; diff --git a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart index 41db48d9..e4c6c4ba 100644 --- a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart +++ b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart @@ -66,6 +66,9 @@ extension PostHogExtension on PostHog { external void startSessionRecording(); external void stopSessionRecording(); external bool sessionRecordingStarted(); + // ignore: non_constant_identifier_names + external void set_config(JSAny config); + external JSObject? get config; external SessionManager? get sessionManager; // ignore: non_constant_identifier_names external void _overrideSDKInfo(JSAny sdkName, JSAny sdkVersion); diff --git a/posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart b/posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart index 98f80e92..25889ff7 100644 --- a/posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart +++ b/posthog_flutter/lib/src/replay/element_parsers/element_object_parser.dart @@ -22,11 +22,16 @@ class ElementObjectParser { } if (element.widget is Text) { - final elementData = _elementParser.relate(element); + final config = Posthog().config?.sessionReplayConfig; + final maskAllTexts = config?.maskAllTexts ?? true; - if (elementData != null) { - activeElementData.addChildren(elementData); - return elementData; + if (maskAllTexts) { + final elementData = _elementParser.relate(element); + + if (elementData != null) { + activeElementData.addChildren(elementData); + return elementData; + } } } diff --git a/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart new file mode 100644 index 00000000..b9e993e1 --- /dev/null +++ b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart @@ -0,0 +1,7 @@ +import 'package:flutter/widgets.dart'; + +/// Canvas masking is a Flutter web concern; on every other platform +/// `PostHogMaskWidget` is honored by the native screenshot pipeline instead. +void notifyMaskWidgetMounted(BuildContext context) {} + +void notifyMaskWidgetUnmounted(BuildContext context) {} diff --git a/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart new file mode 100644 index 00000000..a606d271 --- /dev/null +++ b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart @@ -0,0 +1,80 @@ +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; + +import '../../util/logging.dart'; +import '../web/web_canvas_mask_provider.dart'; +import 'posthog_mask_controller.dart'; + +/// A mounted `PostHogMaskWidget` is an explicit request for masking, so it +/// opts the app into canvas masking even when `posthog.init` never declared +/// `maskRegionsFn`. +/// +/// Deferred to the end of the frame because `initState` runs during Flutter's +/// build phase: registering calls straight into posthog-js and restarts an +/// in-flight recording. +void notifyMaskWidgetMounted(BuildContext context) { + WebCanvasMaskProvider.registerMaskWidgetContext(context); + SchedulerBinding.instance.addPostFrameCallback((_) { + try { + if (!_isInTrackedTree(context)) { + printIfDebug( + 'PostHog: this PostHogMaskWidget is outside the PostHogWidget tree ' + 'PostHog tracks, so masking could never cover it — it does not ' + 'enable web canvas masking.', + ); + return; + } + WebCanvasMaskProvider.notifyMaskWidgetMounted(); + } catch (e) { + printIfDebug('PostHog: error enabling web canvas masking: $e'); + } + }); +} + +void notifyMaskWidgetUnmounted(BuildContext context) { + WebCanvasMaskProvider.unregisterMaskWidgetContext(context); +} + +/// The masking walk only sees the tracked tree (whose route-dependent root is +/// [WebCanvasMaskProvider.trackedTreeRoot], the boundary this check walks +/// against), so a mask widget outside it would opt masking in while its own +/// rects are never produced — the walk would succeed and ship rects that do +/// not cover the widget. With +/// no tracked tree at all the opt-in stays allowed: every walk then fails and +/// frames are skipped (fail closed), which is the documented behavior for an +/// app missing PostHogWidget. +/// +/// The check runs once, in the mount's post-frame callback: a null tracked +/// context at that moment is treated as the no-PostHogWidget shape and +/// allowed. That one-shot allowance is backstopped by +/// [WebCanvasMaskProvider], which revalidates every mounted mask widget when +/// regions are computed — if a PostHogWidget later mounts without containing +/// this widget, frames are skipped (fail closed) rather than recorded +/// unmasked. +bool _isInTrackedTree(BuildContext context) { + final trackedContext = + PostHogMaskController.instance.containerKey.currentContext; + if (trackedContext == null) { + return true; + } + final tracked = WebCanvasMaskProvider.trackedTreeRoot(trackedContext); + if (tracked == null) { + // cannot prove the mask widget is outside the tracked tree + return true; + } + if (!context.mounted) { + return false; + } + final renderObject = context.findRenderObject(); + if (renderObject == null) { + return false; + } + RenderObject? node = renderObject; + while (node != null) { + if (identical(node, tracked)) { + return true; + } + node = node.parent; + } + return false; +} diff --git a/posthog_flutter/lib/src/replay/mask/posthog_mask_controller.dart b/posthog_flutter/lib/src/replay/mask/posthog_mask_controller.dart index 419c3792..15dd6a73 100644 --- a/posthog_flutter/lib/src/replay/mask/posthog_mask_controller.dart +++ b/posthog_flutter/lib/src/replay/mask/posthog_mask_controller.dart @@ -11,24 +11,36 @@ import 'package:posthog_flutter/src/replay/mask/widget_elements_decipher.dart'; import 'package:posthog_flutter/src/util/logging.dart'; class PostHogMaskController { - late final Map parsers; + Map parsers; final GlobalKey containerKey = GlobalKey(); final WidgetElementsDecipher _widgetScraper; PostHogMaskController._privateConstructor(PostHogSessionReplayConfig? config) - : _widgetScraper = WidgetElementsDecipher( + : parsers = _buildParsers(config), + _widgetScraper = WidgetElementsDecipher( elementDataFactory: ElementDataFactory(), elementObjectParser: ElementObjectParser(), rootElementProvider: RootElementProvider(), - ) { - parsers = ElementParsersConst( + ); + + static Map _buildParsers( + PostHogSessionReplayConfig? config, + ) { + return ElementParsersConst( DefaultElementParserFactory(), config, ).parsersMap; } + /// Rebuilds the parser map for [config]. The singleton captures the config + /// present at first access, which a later `setup()` with different masking + /// flags would otherwise never update. + void refreshParsers(PostHogSessionReplayConfig? config) { + parsers = _buildParsers(config); + } + static final PostHogMaskController instance = PostHogMaskController._privateConstructor( Posthog().config?.sessionReplayConfig, @@ -61,6 +73,39 @@ class PostHogMaskController { } } + /// Single-walk variant used by web canvas masking: one [parseRenderTree] + /// producing both the explicit-mask set and (optionally) the full text/image + /// set, instead of two separate walks. Returns null when the tree can't be + /// walked (no [PostHogWidget] mounted, or parsing failed) so callers can + /// fail closed. + List? getMaskElements({required bool includeAllWidgets}) { + final context = containerKey.currentContext; + + if (context == null) { + printIfDebug('Error: containerKey.currentContext is null.'); + return null; + } + + try { + final widgetElementsTree = _widgetScraper.parseRenderTree(context); + + if (widgetElementsTree == null) { + printIfDebug('Error: widgetElementsTree is null after parsing.'); + return null; + } + + return [ + ...widgetElementsTree.extractMaskWidgetRects(), + if (includeAllWidgets) ...widgetElementsTree.extractRects(), + ]; + } catch (e) { + printIfDebug( + 'Error during render tree parsing or rectangle extraction: $e', + ); + return null; + } + } + List? getPostHogWidgetWrapperElements() { final context = containerKey.currentContext; diff --git a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart index 0ddc3af6..6db8927e 100644 --- a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart +++ b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart @@ -1,9 +1,30 @@ import 'package:flutter/material.dart'; +import 'canvas_mask_registration_io.dart' + if (dart.library.js_interop) 'canvas_mask_registration_web.dart'; + /// Masks a widget subtree in PostHog session replay snapshots. /// /// Wrap sensitive UI with [PostHogMaskWidget] to hide that area in captured /// screenshots, regardless of the global session replay masking settings. +/// +/// **Flutter web:** the canvas is masked by posthog-js rather than by this +/// plugin, so the first [PostHogMaskWidget] to mount turns canvas masking on — +/// which restarts an in-flight recording once, because masking also excludes +/// the Flutter semantics DOM tree via `blockSelector`, and posthog-js only +/// reads that when recording starts. Canvas recording itself must be enabled +/// separately — either with `captureCanvas: { recordCanvas: true }` as shown +/// below, or with the canvas capture toggle in your project's session replay +/// settings. Frames captured before that first mount are recorded unmasked; +/// to cover the window between `posthog.init` and Flutter booting, declare +/// `session_recording: { captureCanvas: { recordCanvas: true }, +/// canvasCapture: { maskRegionsFn: () => null } }` +/// in your `posthog.init` call — until this plugin takes over, those frames are +/// skipped instead of recorded. Your app must be wrapped in `PostHogWidget`, +/// and every [PostHogMaskWidget] must sit inside it — otherwise canvas frames +/// are skipped instead of recorded unmasked, until the mask widget is moved +/// inside `PostHogWidget` or removed. iOS and Android need no setup either +/// way. class PostHogMaskWidget extends StatefulWidget { /// The widget subtree to mask in session replay snapshots. final Widget child; @@ -22,10 +43,12 @@ class PostHogMaskWidgetState extends State { @override void initState() { super.initState(); + notifyMaskWidgetMounted(context); } @override void dispose() { + notifyMaskWidgetUnmounted(context); super.dispose(); } diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_geometry.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_geometry.dart new file mode 100644 index 00000000..83ed9ff1 --- /dev/null +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_geometry.dart @@ -0,0 +1,22 @@ +import 'package:flutter/rendering.dart'; + +import '../element_parsers/element_data.dart'; + +/// Converts parsed widget elements to axis-aligned mask rects in the +/// PostHogWidget container's coordinate space. +List containerMaskRects(List elements) { + final rects = []; + for (final element in elements) { + final transform = element.transform; + final rect = transform != null + ? MatrixUtils.transformRect(transform, element.rect) + : element.rect; + if (!rect.isFinite || rect.isEmpty) { + continue; + } + // outset so capture-resolution rounding can't leave a sub-pixel glyph + // edge visible at the mask border + rects.add(rect.inflate(1.0)); + } + return rects; +} diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart new file mode 100644 index 00000000..13665369 --- /dev/null +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -0,0 +1,645 @@ +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; +import 'dart:ui_web' as ui_web; + +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart' + show BuildContext, ModalRoute, Navigator, View; +import 'package:web/web.dart' as web; + +import '../../posthog_config.dart'; +import '../../posthog_flutter_web_handler.dart'; +import '../../util/logging.dart'; +import '../mask/posthog_mask_controller.dart'; +import 'web_canvas_mask_geometry.dart'; + +extension type _JSMaskRegion._(JSObject _) implements JSObject { + external factory _JSMaskRegion({ + required double x, + required double y, + required double width, + required double height, + }); +} + +@JS('Object.assign') +external JSObject _objectAssign(JSObject target, JSObject source); + +const _semanticsBlockSelector = 'flt-semantics-host'; + +// First posthog-js release with canvasCapture.maskRegionsFn support +// (github.com/PostHog/posthog-js#4270). +const _minPosthogJsVersion = '1.408.0'; + +enum _ApplyResult { + applied, + + /// posthog-js is up but the app has not opted into canvas masking. + notOptedIn, + posthogNotReady, +} + +/// Supplies widget-tree mask rectangles to posthog-js canvas recording via +/// `session_recording.canvasCapture.maskRegionsFn`, so text painted +/// into the CanvasKit canvas can be masked even though DOM masking cannot +/// see it. +/// +/// An app opts in either by declaring `maskRegionsFn` in its +/// `posthog.init` call — declaring it as `() => null` also covers the frames +/// captured before this provider takes over — or by mounting a +/// `PostHogMaskWidget`, which registers the provider on first mount. Without +/// either, nothing is registered and recording is left exactly as posthog-js +/// configured it. +/// +/// Fails closed: a failed widget-tree walk returns null, which makes +/// posthog-js skip the frame instead of shipping it unmasked. +class WebCanvasMaskProvider { + WebCanvasMaskProvider(this._config); + + static WebCanvasMaskProvider? _active; + static bool _maskWidgetSeen = false; + static bool _warnedOldPosthogJs = false; + static final Set _mountedMaskWidgets = {}; + + @visibleForTesting + static String? debugMinPosthogJsVersionOverride; + + // the test harness runs full-page, where the real host is and can + // never be distinguished from a foreign view's host + @visibleForTesting + static web.Element? debugOwnViewHostOverride; + + /// Opts the app into canvas masking because a `PostHogMaskWidget` mounted. + /// + /// Called from shared widget code through a conditional import, so it must + /// stay safe to call any number of times; a mount that happens before + /// [register] is remembered and applied when [register] runs. + static void notifyMaskWidgetMounted() { + if (_maskWidgetSeen) { + return; + } + _maskWidgetSeen = true; + try { + _active?._onMaskWidgetMounted(); + } catch (e) { + printIfDebug('PostHog: error enabling web canvas masking: $e'); + } + } + + static void registerMaskWidgetContext(BuildContext context) { + _mountedMaskWidgets.add(context); + } + + /// The render object rooting the tree the masking walk actually covers, + /// mirroring `RootElementProvider`: when the tracked context sits under an + /// active route the walk roots at the root navigator (so it covers dialogs + /// and other routes), otherwise at the tracked context itself. Every + /// tracked-tree membership check must use this same root, or a mask widget + /// in a dialog the walk covers would be treated as untracked. + static RenderObject? trackedTreeRoot(BuildContext trackedContext) { + try { + if (ModalRoute.of(trackedContext)?.isActive ?? false) { + return Navigator.of(trackedContext, rootNavigator: true) + .context + .findRenderObject(); + } + } catch (e) { + printIfDebug('PostHog: could not resolve the tracked-tree root: $e'); + } + return trackedContext.findRenderObject(); + } + + static void unregisterMaskWidgetContext(BuildContext context) { + _mountedMaskWidgets.remove(context); + } + + @visibleForTesting + static void resetForTesting() { + _active?._retryTimer?.cancel(); + _active = null; + _maskWidgetSeen = false; + _mountedMaskWidgets.clear(); + _warnedOldPosthogJs = false; + debugMinPosthogJsVersionOverride = null; + debugOwnViewHostOverride = null; + } + + final PostHogConfig _config; + + Timer? _retryTimer; + List? _cachedContainerRects; + int _cachedAtFrame = -1; + int _consecutiveWalkFailures = 0; + bool _warnedWalkFailure = false; + bool _warnedMaskWidgetOutsideTree = false; + bool _warnedAmbiguousHost = false; + bool _applied = false; + bool _maskWidgetMounted = false; + bool _polling = false; + bool _pendingRestart = false; + + // shared so a second provider's cache is not compared against a counter that + // never advances; the callback cannot be removed once added + static int _frameCount = 0; + static bool _frameCallbackRegistered = false; + + void register() { + try { + // a second setup() must not leave the predecessor's retry chain + // polling — it would apply config captured from the old Posthog config + _active?._retryTimer?.cancel(); + _active = this; + _maskWidgetMounted = _maskWidgetSeen; + // the controller singleton may predate this setup() and still hold a + // parser map built from an older config's masking flags + PostHogMaskController.instance + .refreshParsers(_config.sessionReplayConfig); + _pump(); + } catch (e) { + // a partial first apply (set_config landed, restart threw) must not end + // the chain — the retry re-runs the whole apply, which is idempotent + printIfDebug('PostHog: failed to register web canvas masking: $e'); + _scheduleRetry(const Duration(milliseconds: 250)); + } + } + + void _onMaskWidgetMounted() { + if (_maskWidgetMounted) { + return; + } + _maskWidgetMounted = true; + // a retry chain still in flight picks the flag up on its next tick + if (_applied || _polling) { + return; + } + try { + _pump(); + } catch (e) { + printIfDebug('PostHog: error enabling web canvas masking: $e'); + // the opt-in is latched, so keep a chain alive to apply it later — + // otherwise a failed first apply would consume the opt-in for good + _scheduleRetry(const Duration(milliseconds: 250)); + } + } + + void _pump() { + if (_apply() != _ApplyResult.posthogNotReady) { + return; + } + // the posthog-js snippet installs a config-less stub and array.js later + // REPLACES window.posthog with the real instance, so each retry must + // re-read the getter; set_config against the stub would merge onto an + // empty base and wipe the user's session_recording config + printIfDebug( + 'PostHog: posthog-js not fully loaded yet, ' + 'retrying canvas mask registration.', + ); + _scheduleRetry(const Duration(milliseconds: 250)); + } + + // polls forever once backed off to 4s: a consent-gated app can call + // posthog.init minutes after Flutter boots, and giving up would silently + // leave its canvas frames skipped (maskRegionsFn stuck at () => null) + void _scheduleRetry(Duration delay) { + // every scheduling path must set _polling, and at most one timer may be + // outstanding — a sibling chain surviving here could outlive the + // cancellation in register() and reapply a stale provider's config + _retryTimer?.cancel(); + _polling = true; + _retryTimer = Timer(delay, () { + // ramped up front so a throwing apply backs off like the + // posthog-not-ready path instead of retrying at a fixed 250ms forever + final doubled = delay * 2; + final next = doubled > const Duration(seconds: 4) + ? const Duration(seconds: 4) + : doubled; + try { + if (_apply() != _ApplyResult.posthogNotReady) { + _polling = false; + return; + } + } catch (e) { + printIfDebug('PostHog: web canvas masking retry failed: $e'); + } + _scheduleRetry(next); + }); + } + + void _ensureFrameCounter() { + if (_frameCallbackRegistered) { + return; + } + SchedulerBinding.instance.addPersistentFrameCallback((_) { + _frameCount++; + }); + _frameCallbackRegistered = true; + } + + // posthog-js constructs its instance with a default config before init() + // runs, so a present config does not mean the app has called init — a + // consent-gated app may init long after Flutter boots. Only __loaded + // (set when init completes) distinguishes the two. + bool _isInitialized(PostHog ph) { + if (ph.config == null) { + return false; + } + final loaded = (ph as JSObject).getProperty('__loaded'.toJS); + return loaded.isA() && (loaded as JSBoolean).toDart; + } + + _ApplyResult _apply() { + if (_applied) { + return _ApplyResult.applied; + } + final ph = posthog; + if (ph == null || !_isInitialized(ph)) { + return _ApplyResult.posthogNotReady; + } + + // shallow-merge on top of any user-provided session_recording config — + // posthog-js set_config replaces the whole session_recording object + final sessionRecording = JSObject(); + final existing = ph.config?.getProperty('session_recording'.toJS); + if (existing.isA()) { + _objectAssign(sessionRecording, existing as JSObject); + } + + final canvasCapture = JSObject(); + final existingCanvasCapture = + sessionRecording.getProperty('canvasCapture'.toJS); + if (existingCanvasCapture.isA()) { + _objectAssign(canvasCapture, existingCanvasCapture as JSObject); + } + // the app opts into canvas masking by declaring maskRegionsFn in + // posthog.init or by mounting a PostHogMaskWidget — registering regardless + // would restart an in-flight recording and drop the semantics tree for + // apps that never asked + if (!canvasCapture.has('maskRegionsFn') && !_maskWidgetMounted) { + _warnNotOptedIn(sessionRecording); + return _ApplyResult.notOptedIn; + } + _warnIfPosthogJsTooOld(ph); + _ensureFrameCounter(); + canvasCapture.setProperty( + 'maskRegionsFn'.toJS, + _computeMaskRegions.toJS, + ); + sessionRecording.setProperty('canvasCapture'.toJS, canvasCapture); + + _blockSemanticsHost(sessionRecording); + + final config = JSObject(); + config.setProperty('session_recording'.toJS, sessionRecording); + ph.set_config(config); + + // blockSelector is only read when rrweb's record() starts, so an in-flight + // recording must be restarted — worth it even with canvas capture off, + // since the semantics exclusion applies to plain DOM recording too (and + // recordCanvas can arrive from remote config after init anyway). + // _pendingRestart survives a stop that succeeded while the matching start + // threw: the retry sees the recording as already stopped and must still + // finish the restart. + if (ph.sessionRecordingStarted()) { + _pendingRestart = true; + ph.stopSessionRecording(); + } + if (_pendingRestart) { + ph.startSessionRecording(); + _pendingRestart = false; + } + // only after the restart: a throw above must leave the apply retryable, + // or the selector never takes effect for this recording + _applied = true; + return _ApplyResult.applied; + } + + // canvas recording can also be switched on from project settings, which the + // plugin cannot read — so only the posthog.init half of the leak is warnable + void _warnNotOptedIn(JSObject sessionRecording) { + printIfDebug( + 'PostHog: Flutter web canvas masking is off — mount a PostHogMaskWidget ' + 'or declare maskRegionsFn in posthog.init to enable it.', + ); + final replayConfig = _config.sessionReplayConfig; + if (!replayConfig.maskAllTexts && !replayConfig.maskAllImages) { + return; + } + final captureCanvas = + sessionRecording.getProperty('captureCanvas'.toJS); + if (!captureCanvas.isA()) { + return; + } + final recordCanvas = + (captureCanvas as JSObject).getProperty('recordCanvas'.toJS); + if (recordCanvas.isA() && (recordCanvas as JSBoolean).toDart) { + web.console.warn( + 'PostHog: canvas session recording is enabled but masking is not, so ' + 'text painted by Flutter is recorded unmasked. Mount a ' + 'PostHogMaskWidget to enable masking, or declare ' + 'maskRegionsFn in posthog.init (see the posthog_flutter ' + 'CHANGELOG for the snippet).' + .toJS, + ); + } + } + + // warns only on a version confirmed older than the minimum: an absent or + // unparseable version (custom bundle, future scheme) is assumed new so the + // gate can never misfire, and registration always proceeds — blockSelector + // still protects the accessibility DOM on old posthog-js + void _warnIfPosthogJsTooOld(PostHog ph) { + if (_warnedOldPosthogJs) { + return; + } + try { + final min = debugMinPosthogJsVersionOverride ?? _minPosthogJsVersion; + final raw = (ph as JSObject).getProperty('version'.toJS); + if (!raw.isA()) { + return; + } + final observed = (raw as JSString).toDart; + if (!_isConfirmedOlder(observed, min)) { + return; + } + _warnedOldPosthogJs = true; + web.console.warn( + 'PostHog: this posthog-js version ($observed) does not support ' + 'canvasCapture.maskRegionsFn, so canvas frames are NOT masked ' + '— upgrade to at least $min for canvas masking. The ' + 'accessibility-DOM exclusion is still applied.' + .toJS, + ); + } catch (e) { + printIfDebug('PostHog: could not check the posthog-js version: $e'); + } + } + + static bool _isConfirmedOlder(String observed, String min) { + final observedParts = _parseSemVerPrefix(observed); + final minParts = _parseSemVerPrefix(min); + if (observedParts == null || minParts == null) { + return false; + } + for (var i = 0; i < 3; i++) { + if (observedParts[i] != minParts[i]) { + return observedParts[i] < minParts[i]; + } + } + return false; + } + + static List? _parseSemVerPrefix(String version) { + final match = RegExp(r'^(\d+)\.(\d+)\.(\d+)').firstMatch(version.trim()); + if (match == null) { + return null; + } + return [for (var i = 1; i <= 3; i++) int.parse(match.group(i)!)]; + } + + // with accessibility enabled, Flutter mirrors widget text into the + // flt-semantics DOM tree, which rrweb would otherwise record in plaintext + void _blockSemanticsHost(JSObject sessionRecording) { + final existing = sessionRecording.getProperty('blockSelector'.toJS); + var selector = _semanticsBlockSelector; + if (existing.isA()) { + final current = (existing as JSString).toDart; + // token-exact: a user selector like `.not-flt-semantics-host` must not + // pass for the semantics-host selector itself + final alreadyBlocked = current + .split(',') + .map((token) => token.trim()) + .contains(_semanticsBlockSelector); + if (alreadyBlocked) { + return; + } + selector = '$current, $_semanticsBlockSelector'; + } + sessionRecording.setProperty('blockSelector'.toJS, selector.toJS); + } + + // null tells posthog-js to skip the frame rather than ship it unmasked + JSArray? _computeMaskRegions(web.HTMLCanvasElement canvas) { + try { + return _unsafeComputeMaskRegions(canvas); + } catch (e) { + printIfDebug('PostHog: error computing canvas mask regions: $e'); + return null; + } + } + + JSArray? _unsafeComputeMaskRegions(web.HTMLCanvasElement canvas) { + final host = _flutterViewHost(canvas); + if (host == null) { + return JSArray(); + } + + // our rects always describe PostHogWidget's tree — shipping them with a + // different flutter-view's canvas would record that view unmasked; checked + // before the walk so foreign canvases neither cost a walk nor advance the + // walk-failure counter + if (!_isOwnViewCanvas(host)) { + return null; + } + + if (!_maskWidgetsInsideTrackedTree()) { + if (!_warnedMaskWidgetOutsideTree) { + _warnedMaskWidgetOutsideTree = true; + printIfDebug( + 'PostHog: a PostHogMaskWidget is mounted outside the PostHogWidget ' + 'tree, so masking could never cover it — canvas frames are skipped ' + 'until it is moved inside PostHogWidget or removed.', + ); + } + return null; + } + + final containerRects = _currentContainerRects(); + if (containerRects == null) { + _noteWalkFailure(); + return null; + } + _consecutiveWalkFailures = 0; + if (containerRects.isEmpty) { + return JSArray(); + } + + // rects are container-local, so map them through the container's full + // transform (an ancestor Transform.scale scales painted content, and a + // plain origin shift would leave the masks at the unscaled size); the + // hostRect term covers a flutter-view embedded away from the viewport + // origin, since Flutter logical pixels == CSS pixels on web + Matrix4? containerTransform; + final containerObject = PostHogMaskController + .instance.containerKey.currentContext + ?.findRenderObject(); + if (containerObject is RenderBox && containerObject.hasSize) { + containerTransform = containerObject.getTransformTo(null); + } + final canvasRect = canvas.getBoundingClientRect(); + final hostRect = host.getBoundingClientRect(); + final offset = Offset( + hostRect.left - canvasRect.left, + hostRect.top - canvasRect.top, + ); + + final regions = []; + for (final rect in containerRects) { + final globalRect = containerTransform == null + ? rect + : MatrixUtils.transformRect(containerTransform, rect); + final shifted = globalRect.shift(offset); + // a singular/perspective ancestor transform yields non-finite + // components, and posthog-js fillRect silently no-ops on those — the + // frame would ship unmasked, so skip it instead + if (!shifted.isFinite) { + return null; + } + regions.add(_JSMaskRegion( + x: shifted.left, + y: shifted.top, + width: shifted.width, + height: shifted.height, + )); + } + return regions.toJS; + } + + // In full-page mode the embedder host is , which contains every + // flutter-view on the page — containment only proves ownership when the + // host holds a single flutter-view. With more, our rects could be paired + // with a foreign view's canvas and record it unmasked, so fail closed. + bool _isOwnViewCanvas(web.Element canvasViewHost) { + final ownHost = debugOwnViewHostOverride ?? _resolveOwnViewHost(); + if (ownHost != null) { + if (ownHost.querySelectorAll('flutter-view').length > 1) { + if (!_warnedAmbiguousHost) { + _warnedAmbiguousHost = true; + printIfDebug( + 'PostHog: multiple Flutter views share one host element, so ' + 'canvas frames are skipped — masks cannot be matched to a view.', + ); + } + return false; + } + return ownHost.contains(canvasViewHost); + } + // unresolvable: claim ownership only when the lone light-DOM flutter-view + // is the canvas's own — a count of 0 with a flutter-view canvas in hand + // means the views sit in shadow roots querySelectorAll cannot see, which + // is ambiguous + final views = web.document.querySelectorAll('flutter-view'); + return views.length == 1 && views.item(0)!.contains(canvasViewHost); + } + + web.Element? _resolveOwnViewHost() { + try { + final context = + PostHogMaskController.instance.containerKey.currentContext; + if (context == null) { + return null; + } + final viewId = View.maybeOf(context)?.viewId; + if (viewId == null) { + return null; + } + final hostElement = ui_web.views.getHostElement(viewId); + if (hostElement != null && hostElement.isA()) { + return hostElement as web.Element; + } + } catch (e) { + printIfDebug('PostHog: could not resolve the Flutter view host: $e'); + } + return null; + } + + // Re-checked on every frame, not only at mount: a mask widget that mounted + // before any PostHogWidget latches the opt-in, and a PostHogWidget mounting + // later without containing it would otherwise ship rects that never cover + // the widget. Deliberately outside the per-frame rects cache, so a cached + // value cannot outlive a tree change. The boundary is [trackedTreeRoot], + // the walk's own route-dependent root — not PostHogWidget's subtree. + bool _maskWidgetsInsideTrackedTree() { + if (_mountedMaskWidgets.isEmpty) { + return true; + } + final trackedContext = + PostHogMaskController.instance.containerKey.currentContext; + if (trackedContext == null) { + return false; + } + final tracked = trackedTreeRoot(trackedContext); + if (tracked == null) { + return false; + } + for (final context in _mountedMaskWidgets) { + if (!context.mounted) { + continue; + } + var inside = false; + RenderObject? node = context.findRenderObject(); + while (node != null) { + if (identical(node, tracked)) { + inside = true; + break; + } + node = node.parent; + } + if (!inside) { + return false; + } + } + return true; + } + + // rects only change when Flutter paints a frame, so cache per frame instead + // of recomputing on every posthog-js canvas tick + List? _currentContainerRects() { + if (_cachedContainerRects != null && _cachedAtFrame == _frameCount) { + return _cachedContainerRects; + } + + final replayConfig = _config.sessionReplayConfig; + final elements = PostHogMaskController.instance.getMaskElements( + includeAllWidgets: + replayConfig.maskAllTexts || replayConfig.maskAllImages, + ); + if (elements == null) { + _cachedContainerRects = null; + return null; + } + + final rects = containerMaskRects(elements); + _cachedContainerRects = rects; + _cachedAtFrame = _frameCount; + return rects; + } + + // PostHogWidget can mount a moment after recording starts, so a few + // fail-closed frames during boot are normal; only a persistent failure + // means the canvas is never recorded + void _noteWalkFailure() { + if (_warnedWalkFailure) { + return; + } + _consecutiveWalkFailures++; + if (_consecutiveWalkFailures >= 10) { + _warnedWalkFailure = true; + web.console.warn( + 'PostHog: session replay masking cannot find the PostHogWidget ' + 'widget tree, so canvas frames are not being recorded. ' + 'Wrap your app in PostHogWidget.' + .toJS, + ); + } + } + + web.Element? _flutterViewHost(web.HTMLCanvasElement canvas) { + final root = canvas.getRootNode(); + final web.Element start = + root.isA() ? (root as web.ShadowRoot).host : canvas; + return start.closest('flutter-view'); + } +} diff --git a/posthog_flutter/test/element_object_parser_test.dart b/posthog_flutter/test/element_object_parser_test.dart new file mode 100644 index 00000000..953ac229 --- /dev/null +++ b/posthog_flutter/test/element_object_parser_test.dart @@ -0,0 +1,82 @@ +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart'; +import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; + +import 'posthog_flutter_platform_interface_fake.dart'; + +void main() { + Future setupPosthog({ + required bool maskAllTexts, + required bool maskAllImages, + }) async { + PosthogFlutterPlatformInterface.instance = PosthogFlutterPlatformFake(); + final config = PostHogConfig('test_project_token'); + config.sessionReplayConfig.maskAllTexts = maskAllTexts; + config.sessionReplayConfig.maskAllImages = maskAllImages; + await Posthog().setup(config); + // the controller singleton may have been created before this setup + PostHogMaskController.instance.refreshParsers(config.sessionReplayConfig); + } + + tearDown(() async { + PostHogMaskController.instance.refreshParsers(null); + await Posthog().close(); + }); + + Future pumpTree(WidgetTester tester) async { + // real async work: it never completes inside the test's FakeAsync zone + final ui.Image image = (await tester.runAsync( + () => createTestImage(width: 20, height: 20), + ))!; + addTearDown(image.dispose); + await tester.pumpWidget( + MaterialApp( + home: RepaintBoundary( + key: PostHogMaskController.instance.containerKey, + child: Scaffold( + body: Column( + children: [ + const Text('some text'), + RawImage(image: image, width: 20, height: 20), + ], + ), + ), + ), + ), + ); + } + + Set types(List elements) => + elements.map((e) => e.type).toSet(); + + testWidgets( + 'maskAllTexts=false keeps Text out of the mask set even when ' + 'maskAllImages is on', (tester) async { + await setupPosthog(maskAllTexts: false, maskAllImages: true); + await pumpTree(tester); + + final elements = + PostHogMaskController.instance.getMaskElements(includeAllWidgets: true); + + expect(elements, isNotNull); + expect(types(elements!), isNot(contains('Text'))); + expect(types(elements), isNot(contains('RichText'))); + expect(types(elements), contains('RawImage')); + }); + + testWidgets('maskAllTexts=true still masks Text', (tester) async { + await setupPosthog(maskAllTexts: true, maskAllImages: true); + await pumpTree(tester); + + final elements = + PostHogMaskController.instance.getMaskElements(includeAllWidgets: true); + + expect(elements, isNotNull); + expect(types(elements!), contains('Text')); + }); +} diff --git a/posthog_flutter/test/posthog_mask_controller_test.dart b/posthog_flutter/test/posthog_mask_controller_test.dart new file mode 100644 index 00000000..77cc6011 --- /dev/null +++ b/posthog_flutter/test/posthog_mask_controller_test.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart'; +import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; + +void main() { + Widget appWithContainerKey() { + return MaterialApp( + home: RepaintBoundary( + key: PostHogMaskController.instance.containerKey, + child: Scaffold( + body: Column( + children: [ + const Text('visible text'), + PostHogMaskWidget(child: const Text('wrapped')), + const TextField(obscureText: true), + ], + ), + ), + ), + ); + } + + Set describe(List elements) { + return elements.map((e) => '${e.type}:${e.rect}').toSet(); + } + + testWidgets('getMaskElements with all widgets matches the two-walk union', + (tester) async { + await tester.pumpWidget(appWithContainerKey()); + + final combined = + PostHogMaskController.instance.getMaskElements(includeAllWidgets: true); + final wrapperOnly = + PostHogMaskController.instance.getPostHogWidgetWrapperElements(); + final allWidgets = + PostHogMaskController.instance.getCurrentWidgetsElements(); + + expect(combined, isNotNull); + expect( + describe(combined!), + describe([...wrapperOnly!, ...allWidgets!]), + ); + expect(combined, isNotEmpty); + }); + + testWidgets('getMaskElements without all widgets matches the wrapper walk', + (tester) async { + await tester.pumpWidget(appWithContainerKey()); + + final combined = PostHogMaskController.instance + .getMaskElements(includeAllWidgets: false); + final wrapperOnly = + PostHogMaskController.instance.getPostHogWidgetWrapperElements(); + + expect(combined, isNotNull); + expect(describe(combined!), describe(wrapperOnly!)); + }); + + test('getMaskElements returns null without a mounted container', () { + expect( + PostHogMaskController.instance.getMaskElements(includeAllWidgets: true), + isNull, + ); + }); + + test('refreshParsers rebuilds the parser map from the new config', () { + final controller = PostHogMaskController.instance; + addTearDown(() => controller.refreshParsers(null)); + + final textsOnly = PostHogConfig('phc_test').sessionReplayConfig + ..maskAllImages = false; + controller.refreshParsers(textsOnly); + expect(controller.parsers.keys, isNot(contains('RenderImage'))); + expect(controller.parsers.keys, contains('RenderParagraph')); + + final imagesToo = PostHogConfig('phc_test').sessionReplayConfig; + controller.refreshParsers(imagesToo); + expect(controller.parsers.keys, contains('RenderImage')); + }); +} diff --git a/posthog_flutter/test/web_canvas_mask_geometry_test.dart b/posthog_flutter/test/web_canvas_mask_geometry_test.dart new file mode 100644 index 00000000..50bf24f8 --- /dev/null +++ b/posthog_flutter/test/web_canvas_mask_geometry_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/replay/element_parsers/element_data.dart'; +import 'package:posthog_flutter/src/replay/web/web_canvas_mask_geometry.dart'; + +void main() { + ElementData element(Rect rect, {Matrix4? transform}) { + return ElementData(rect: rect, type: 'Text', transform: transform); + } + + test('outsets plain rects by one pixel', () { + final rects = containerMaskRects([ + element(const Rect.fromLTWH(10, 20, 30, 40)), + ]); + + expect(rects, [const Rect.fromLTWH(9, 19, 32, 42)]); + }); + + test('applies the element transform before outsetting', () { + final rects = containerMaskRects([ + element( + const Rect.fromLTWH(0, 0, 10, 10), + transform: Matrix4.translationValues(100, 200, 0) + ..scaleByDouble(2.0, 2.0, 2.0, 1.0), + ), + ]); + + expect(rects, [const Rect.fromLTWH(99, 199, 22, 22)]); + }); + + test('bounds rotated elements with an axis-aligned rect', () { + final rects = containerMaskRects([ + element( + const Rect.fromLTWH(0, 0, 10, 10), + transform: Matrix4.rotationZ(0.5), + ), + ]); + + expect(rects, hasLength(1)); + final rect = rects.single; + expect( + rect.contains(MatrixUtils.transformPoint( + Matrix4.rotationZ(0.5), + const Offset(10, 10), + )), + isTrue); + expect(rect.contains(const Offset(0, 0)), isTrue); + }); + + test('drops empty and non-finite rects before outsetting', () { + final rects = containerMaskRects([ + element(Rect.zero.deflate(2)), + element(Rect.zero), + element(const Rect.fromLTWH(3, 4, 0, 10)), + element(const Rect.fromLTWH(0, 0, double.infinity, 10)), + element(const Rect.fromLTWH(0, 0, 5, 5)), + ]); + + expect(rects, [const Rect.fromLTWH(-1, -1, 7, 7)]); + }); +} diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart new file mode 100644 index 00000000..5950e10e --- /dev/null +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -0,0 +1,1184 @@ +@TestOn('browser') +library; + +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:posthog_flutter/src/replay/web/web_canvas_mask_provider.dart'; +import 'package:web/web.dart' as web; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + JSObject? capturedConfig; + var setConfigCalls = 0; + var stopRecordingCalls = 0; + var startRecordingCalls = 0; + + JSObject installPosthogStub({ + JSObject? sessionRecording, + bool withConfig = true, + bool loaded = true, + bool recordingStarted = false, + bool declaresMaskProvider = true, + String? version, + }) { + capturedConfig = null; + setConfigCalls = 0; + stopRecordingCalls = 0; + startRecordingCalls = 0; + final stub = JSObject(); + if (withConfig) { + final config = JSObject(); + if (declaresMaskProvider) { + sessionRecording ??= JSObject(); + final existing = + sessionRecording.getProperty('canvasCapture'.toJS); + final canvasCapture = + existing.isA() ? existing as JSObject : JSObject(); + canvasCapture.setProperty('maskRegionsFn'.toJS, null); + sessionRecording.setProperty('canvasCapture'.toJS, canvasCapture); + } + if (sessionRecording != null) { + config.setProperty('session_recording'.toJS, sessionRecording); + } + stub.setProperty('config'.toJS, config); + // the real instance carries __loaded; the snippet stub (withConfig: + // false) has neither config nor __loaded + stub.setProperty('__loaded'.toJS, loaded.toJS); + } + stub.setProperty( + 'set_config'.toJS, + ((JSObject cfg) { + capturedConfig = cfg; + setConfigCalls++; + }).toJS, + ); + var recordingState = recordingStarted; + stub.setProperty( + 'sessionRecordingStarted'.toJS, + (() => recordingState.toJS).toJS, + ); + stub.setProperty( + 'stopSessionRecording'.toJS, + (() { + stopRecordingCalls++; + recordingState = false; + }).toJS, + ); + stub.setProperty( + 'startSessionRecording'.toJS, + (() { + startRecordingCalls++; + recordingState = true; + }).toJS, + ); + if (version != null) { + stub.setProperty('version'.toJS, version.toJS); + } + web.window.setProperty('posthog'.toJS, stub); + return stub; + } + + int Function() interceptWarns() { + final consoleObject = web.window.getProperty('console'.toJS); + final originalWarn = consoleObject.getProperty('warn'.toJS); + var warnCalls = 0; + consoleObject.setProperty( + 'warn'.toJS, + ((JSAny? message) { + warnCalls++; + }).toJS, + ); + addTearDown(() => consoleObject.setProperty('warn'.toJS, originalWarn)); + return () => warnCalls; + } + + // cancel the previous test's retry chain so a stale chain cannot apply + // config against this test's stub + setUp(WebCanvasMaskProvider.resetForTesting); + + tearDown(() { + WebCanvasMaskProvider.resetForTesting(); + web.window.setProperty('posthog'.toJS, null); + }); + + JSObject capturedSessionRecording() { + expect(capturedConfig, isNotNull); + final sessionRecording = + capturedConfig!.getProperty('session_recording'.toJS); + expect(sessionRecording.isA(), isTrue); + return sessionRecording as JSObject; + } + + test('leaves posthog-js untouched when the app declares no mask provider', + () { + installPosthogStub(declaresMaskProvider: true, recordingStarted: true); + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(capturedConfig, isNull); + expect(stopRecordingCalls, 0); + expect(startRecordingCalls, 0); + }); + + testWidgets('opts in when a PostHogMaskWidget mounted before register()', + (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + expect(setConfigCalls, 0); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(setConfigCalls, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + testWidgets('a mounted PostHogMaskWidget opts the app in on its own', + (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + expect(capturedConfig, isNull); + + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + + final sessionRecording = capturedSessionRecording(); + expect( + sessionRecording + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS) + .isA(), + isTrue, + ); + expect( + sessionRecording.getProperty('blockSelector'.toJS).dartify(), + 'flt-semantics-host', + ); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + testWidgets('registers once however many PostHogMaskWidgets mount', + (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + Widget maskWidgets(int count) => Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: List.generate( + count, + (_) => const PostHogMaskWidget(child: SizedBox.shrink()), + ), + ), + ); + await tester.pumpWidget(maskWidgets(3)); + await tester.pumpWidget(maskWidgets(5)); + + expect(setConfigCalls, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + testWidgets( + 'a PostHogMaskWidget mount is a no-op when posthog.init already ' + 'opted in', (tester) async { + installPosthogStub(recordingStarted: true); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + expect(setConfigCalls, 1); + + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + + expect(setConfigCalls, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + testWidgets( + 'an exception during the mount-triggered apply does not consume the ' + 'opt-in', (tester) async { + final stub = installPosthogStub(declaresMaskProvider: false); + var calls = 0; + stub.setProperty( + 'set_config'.toJS, + ((JSObject cfg) { + calls++; + if (calls == 1) { + throw StateError('stub failure'); + } + capturedConfig = cfg; + }).toJS, + ); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + expect(capturedConfig, isNull); + + await tester.pump(const Duration(milliseconds: 600)); + + expect(calls, 2); + expect(capturedConfig, isNotNull); + }); + + testWidgets('opts in once posthog-js arrives after the widget mounted', + (tester) async { + web.window.setProperty('posthog'.toJS, null); + capturedConfig = null; + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + expect(capturedConfig, isNull); + + installPosthogStub(declaresMaskProvider: false); + await tester.pump(const Duration(milliseconds: 300)); + + expect(setConfigCalls, 1); + }); + + testWidgets( + 'a retry chain from a throw-path apply cannot outlive a second ' + 'register()', (tester) async { + final stub = installPosthogStub(recordingStarted: true); + var successfulSetConfigs = 0; + var failing = true; + stub.setProperty( + 'set_config'.toJS, + ((JSObject cfg) { + if (failing) { + throw StateError('stub failure'); + } + successfulSetConfigs++; + capturedConfig = cfg; + }).toJS, + ); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + // the first apply threw, so a retry is pending; a mask widget mounting + // now must join that chain instead of starting a second one + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + expect(successfulSetConfigs, 0); + + failing = false; + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + expect(successfulSetConfigs, 1); + + // an orphaned first-chain timer would fire here and apply a second time + await tester.pump(const Duration(seconds: 5)); + + expect(successfulSetConfigs, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + testWidgets( + 'a mask widget outside the tracked PostHogWidget tree does not opt in', + (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + Expanded(child: PostHogWidget(child: Container())), + const PostHogMaskWidget(child: SizedBox.shrink()), + ], + ), + ), + ); + + expect(setConfigCalls, 0); + expect(stopRecordingCalls, 0); + expect(startRecordingCalls, 0); + }); + + testWidgets( + 'a mask widget that mounts before any PostHogWidget opts in ' + '(no-PostHogWidget shape), but a later PostHogWidget that excludes it ' + 'makes frames fail closed', (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + Widget layout({required bool withPostHogWidget}) => Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + if (withPostHogWidget) + Expanded(child: PostHogWidget(child: Container())), + const PostHogMaskWidget(child: SizedBox.shrink()), + ], + ), + ); + + await tester.pumpWidget(layout(withPostHogWidget: false)); + expect(setConfigCalls, 1); + + await tester.pumpWidget(layout(withPostHogWidget: true)); + + expect(setConfigCalls, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + + // the latched opt-in must not ship the sibling tree's rects while this + // mask widget sits outside it + final flutterView = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; + try { + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + expect(regionsFn.callAsFunction(null, canvas), isNull); + } finally { + flutterView.remove(); + } + }); + + testWidgets( + 'a mask widget that mounts before a PostHogWidget that later contains ' + 'it keeps producing regions', (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false + ..sessionReplayConfig.maskAllImages = false; + WebCanvasMaskProvider(config).register(); + + final maskKey = GlobalKey(); + Widget mask() => Align( + alignment: Alignment.topLeft, + child: PostHogMaskWidget( + key: maskKey, + child: const SizedBox(width: 30, height: 40), + ), + ); + + await tester.pumpWidget(mask()); + expect(setConfigCalls, 1); + + await tester.pumpWidget(PostHogWidget(child: mask())); + + final flutterView = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; + try { + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + final regions = + regionsFn.callAsFunction(null, canvas) as JSArray; + + expect(regions.toDart, hasLength(1)); + final region = regions.toDart.first; + expect( + region.getProperty('width'.toJS).toDartDouble, + greaterThanOrEqualTo(30), + ); + expect( + region.getProperty('height'.toJS).toDartDouble, + greaterThanOrEqualTo(40), + ); + } finally { + flutterView.remove(); + } + }); + + testWidgets( + 'frames recover once the mask widget outside the tracked tree is ' + 'removed', (tester) async { + installPosthogStub(recordingStarted: true); + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false + ..sessionReplayConfig.maskAllImages = false; + WebCanvasMaskProvider(config).register(); + + Widget layout({required bool withOutsideMask}) => Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + Expanded(child: PostHogWidget(child: Container())), + if (withOutsideMask) + const PostHogMaskWidget(child: SizedBox.shrink()), + ], + ), + ); + + await tester.pumpWidget(layout(withOutsideMask: true)); + + final flutterView = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; + try { + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + expect(regionsFn.callAsFunction(null, canvas), isNull); + + await tester.pumpWidget(layout(withOutsideMask: false)); + + expect(regionsFn.callAsFunction(null, canvas), isNotNull); + } finally { + flutterView.remove(); + } + }); + + testWidgets('a mask widget inside the tracked PostHogWidget tree opts in', + (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + await tester.pumpWidget( + PostHogWidget(child: PostHogMaskWidget(child: const SizedBox.shrink())), + ); + + expect(setConfigCalls, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + testWidgets( + 'a mask widget in a dialog above PostHogWidget opts in and produces ' + 'regions', (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false + ..sessionReplayConfig.maskAllImages = false; + WebCanvasMaskProvider(config).register(); + expect(setConfigCalls, 0); + + // PostHogWidget sits UNDER the home route, so the walk roots at the root + // navigator and covers the dialog — the tracked-tree boundary must too + await tester.pumpWidget( + MaterialApp( + home: PostHogWidget( + child: Builder( + builder: (context) => TextButton( + onPressed: () { + showDialog( + context: context, + builder: (_) => Align( + alignment: Alignment.topLeft, + child: PostHogMaskWidget( + child: const SizedBox(width: 30, height: 40), + ), + ), + ); + }, + child: const Text('open'), + ), + ), + ), + ), + ); + expect(setConfigCalls, 0); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(setConfigCalls, 1); + + final flutterView = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; + try { + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + final regions = regionsFn.callAsFunction(null, canvas); + expect(regions, isNotNull); + expect((regions as JSArray).toDart, isNotEmpty); + } finally { + flutterView.remove(); + } + }); + + test('registers the mask provider via set_config', () { + installPosthogStub(); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + final sessionRecording = capturedSessionRecording(); + final canvasCapture = + sessionRecording.getProperty('canvasCapture'.toJS); + expect( + canvasCapture.getProperty('maskRegionsFn'.toJS).isA(), + isTrue, + ); + expect( + sessionRecording.getProperty('blockSelector'.toJS).dartify(), + 'flt-semantics-host', + ); + }); + + test('preserves existing session_recording config when merging', () { + final existingCanvasCapture = JSObject() + ..setProperty('resolutionScale'.toJS, 0.5.toJS); + final existingCaptureCanvas = JSObject() + ..setProperty('recordCanvas'.toJS, true.toJS) + ..setProperty('canvasFps'.toJS, 2.toJS); + final existingSessionRecording = JSObject() + ..setProperty('blockSelector'.toJS, '.secret'.toJS) + ..setProperty('maskAllInputs'.toJS, false.toJS) + ..setProperty('canvasCapture'.toJS, existingCanvasCapture) + ..setProperty('captureCanvas'.toJS, existingCaptureCanvas); + installPosthogStub(sessionRecording: existingSessionRecording); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + final sessionRecording = capturedSessionRecording(); + expect( + sessionRecording.getProperty('maskAllInputs'.toJS).dartify(), + false, + ); + expect( + sessionRecording.getProperty('blockSelector'.toJS).dartify(), + '.secret, flt-semantics-host', + ); + final canvasCapture = + sessionRecording.getProperty('canvasCapture'.toJS); + expect( + canvasCapture.getProperty('resolutionScale'.toJS).dartify(), + 0.5, + ); + expect( + canvasCapture.getProperty('maskRegionsFn'.toJS).isA(), + isTrue, + ); + final captureCanvas = + sessionRecording.getProperty('captureCanvas'.toJS); + expect( + captureCanvas.getProperty('recordCanvas'.toJS).dartify(), + true, + ); + expect( + captureCanvas.getProperty('canvasFps'.toJS).dartify(), + 2, + ); + expect( + captureCanvas.getProperty('maskRegionsFn'.toJS), + isNull, + ); + }); + + test( + 'appends the semantics selector when an existing one only contains it ' + 'as a substring', () { + final existingSessionRecording = JSObject() + ..setProperty('blockSelector'.toJS, '.not-flt-semantics-host'.toJS); + installPosthogStub(sessionRecording: existingSessionRecording); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect( + capturedSessionRecording() + .getProperty('blockSelector'.toJS) + .dartify(), + '.not-flt-semantics-host, flt-semantics-host', + ); + }); + + test('blocks the semantics host even when maskAllTexts is false', () { + installPosthogStub(); + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false; + + WebCanvasMaskProvider(config).register(); + + final sessionRecording = capturedSessionRecording(); + expect( + sessionRecording.getProperty('blockSelector'.toJS).dartify(), + 'flt-semantics-host', + ); + }); + + test('returns no regions for a canvas outside the flutter view', () { + installPosthogStub(); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + final canvas = web.document.createElement('canvas'); + final regions = regionsFn.callAsFunction(null, canvas) as JSArray; + + expect(regions.toDart, isEmpty); + }); + + test( + 'fails closed for a flutter-view canvas when no PostHogWidget is ' + 'mounted', () { + installPosthogStub(); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + final flutterView = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; + try { + expect(regionsFn.callAsFunction(null, canvas), isNull); + } finally { + flutterView.remove(); + } + }); + + test('warns once when the widget-tree walk keeps failing', () { + installPosthogStub(); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + final consoleObject = web.window.getProperty('console'.toJS); + final originalWarn = consoleObject.getProperty('warn'.toJS); + var warnCalls = 0; + consoleObject.setProperty( + 'warn'.toJS, + ((JSAny? message) { + warnCalls++; + }).toJS, + ); + + final flutterView = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; + try { + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + for (var i = 0; i < 12; i++) { + expect(regionsFn.callAsFunction(null, canvas), isNull); + } + expect(warnCalls, 1); + } finally { + flutterView.remove(); + consoleObject.setProperty('warn'.toJS, originalWarn); + } + }); + + testWidgets('maps mask rects into canvas-relative coordinates', + (tester) async { + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false + ..sessionReplayConfig.maskAllImages = false; + + await tester.pumpWidget( + PostHogWidget( + child: Padding( + padding: const EdgeInsets.only(left: 100, top: 200), + child: Align( + alignment: Alignment.topLeft, + child: PostHogMaskWidget( + child: const SizedBox(width: 30, height: 40), + ), + ), + ), + ), + ); + + final flutterView = web.document.createElement('flutter-view'); + flutterView.setAttribute('style', 'position: fixed; left: 50px; top: 60px'); + final canvas = web.document.createElement('canvas'); + canvas.setAttribute('style', 'position: absolute; left: 20px; top: 30px'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + + installPosthogStub(); + try { + // the harness's own flutter-view plus this fake one make the real + // host ambiguous, which now fails closed + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; + WebCanvasMaskProvider(config).register(); + + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + final regions = + regionsFn.callAsFunction(null, canvas) as JSArray; + + expect(regions.toDart, hasLength(1)); + final region = regions.toDart.first; + expect(region.getProperty('x'.toJS).toDartDouble, 79); + expect(region.getProperty('y'.toJS).toDartDouble, 169); + expect(region.getProperty('width'.toJS).toDartDouble, 32); + expect(region.getProperty('height'.toJS).toDartDouble, 42); + } finally { + flutterView.remove(); + } + }); + + testWidgets('scales mask rects by an ancestor transform around the container', + (tester) async { + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false + ..sessionReplayConfig.maskAllImages = false; + + await tester.pumpWidget( + Transform.scale( + scale: 2, + alignment: Alignment.topLeft, + child: PostHogWidget( + child: Align( + alignment: Alignment.topLeft, + child: PostHogMaskWidget( + child: const SizedBox(width: 30, height: 40), + ), + ), + ), + ), + ); + + final flutterView = web.document.createElement('flutter-view'); + flutterView.setAttribute('style', 'position: fixed; left: 0; top: 0'); + final canvas = web.document.createElement('canvas'); + canvas.setAttribute('style', 'position: absolute; left: 0; top: 0'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + + installPosthogStub(); + try { + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; + WebCanvasMaskProvider(config).register(); + + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + final regions = + regionsFn.callAsFunction(null, canvas) as JSArray; + + // container-local (0,0,30,40) outsets to (-1,-1,32,42), then doubles + expect(regions.toDart, hasLength(1)); + final region = regions.toDart.first; + expect(region.getProperty('x'.toJS).toDartDouble, -2); + expect(region.getProperty('y'.toJS).toDartDouble, -2); + expect(region.getProperty('width'.toJS).toDartDouble, 64); + expect(region.getProperty('height'.toJS).toDartDouble, 84); + } finally { + flutterView.remove(); + } + }); + + testWidgets('fails closed when an ancestor transform is singular', + (tester) async { + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false + ..sessionReplayConfig.maskAllImages = false; + + await tester.pumpWidget( + Transform( + transform: Matrix4.identity()..setEntry(3, 3, 0), + child: PostHogWidget( + child: Align( + alignment: Alignment.topLeft, + child: PostHogMaskWidget( + child: const SizedBox(width: 30, height: 40), + ), + ), + ), + ), + ); + + final flutterView = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + + installPosthogStub(); + try { + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; + WebCanvasMaskProvider(config).register(); + + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + + expect(regionsFn.callAsFunction(null, canvas), isNull); + } finally { + flutterView.remove(); + } + }); + + testWidgets( + 'fails closed for a canvas in a foreign flutter-view on a multi-view ' + 'page', (tester) async { + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false + ..sessionReplayConfig.maskAllImages = false; + + await tester.pumpWidget( + PostHogWidget( + child: Align( + alignment: Alignment.topLeft, + child: PostHogMaskWidget( + child: const SizedBox(width: 30, height: 40), + ), + ), + ), + ); + + web.Element embeddedView(web.Element host) { + final view = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + view.appendChild(canvas); + host.appendChild(view); + return canvas; + } + + final ownHost = web.document.createElement('div'); + final ownCanvas = embeddedView(ownHost); + final foreignHost = web.document.createElement('div'); + final foreignCanvas = embeddedView(foreignHost); + web.document.body!.appendChild(ownHost); + web.document.body!.appendChild(foreignHost); + + installPosthogStub(); + try { + WebCanvasMaskProvider.debugOwnViewHostOverride = ownHost; + WebCanvasMaskProvider(config).register(); + + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + + expect(regionsFn.callAsFunction(null, foreignCanvas), isNull); + final regions = + regionsFn.callAsFunction(null, ownCanvas) as JSArray; + expect(regions.toDart, hasLength(1)); + } finally { + ownHost.remove(); + foreignHost.remove(); + } + }); + + testWidgets( + 'fails closed for every canvas when the host contains more than one ' + 'flutter-view', (tester) async { + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false + ..sessionReplayConfig.maskAllImages = false; + + await tester.pumpWidget( + PostHogWidget( + child: Align( + alignment: Alignment.topLeft, + child: PostHogMaskWidget( + child: const SizedBox(width: 30, height: 40), + ), + ), + ), + ); + + web.Element embeddedView(web.Element host) { + final view = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + view.appendChild(canvas); + host.appendChild(view); + return canvas; + } + + // full-page mode: getHostElement resolves to , which hosts both + // our view and the foreign one + final sharedHost = web.document.createElement('div'); + final ownCanvas = embeddedView(sharedHost); + final foreignCanvas = embeddedView(sharedHost); + web.document.body!.appendChild(sharedHost); + + installPosthogStub(); + try { + WebCanvasMaskProvider.debugOwnViewHostOverride = sharedHost; + WebCanvasMaskProvider(config).register(); + + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + + expect(regionsFn.callAsFunction(null, ownCanvas), isNull); + expect(regionsFn.callAsFunction(null, foreignCanvas), isNull); + } finally { + sharedHost.remove(); + } + }); + + test('defers set_config until posthog-js exposes its config', () { + installPosthogStub(withConfig: false); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(capturedConfig, isNull); + }); + + test('applies config after posthog-js appears with no snippet stub at all', + () async { + web.window.setProperty('posthog'.toJS, null); + capturedConfig = null; + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + expect(capturedConfig, isNull); + + installPosthogStub(); + await Future.delayed(const Duration(milliseconds: 1500)); + + expect(capturedConfig, isNotNull); + }); + + test( + 'applies config after array.js replaces the snippet stub with the real ' + 'instance', () async { + installPosthogStub(withConfig: false); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + expect(capturedConfig, isNull); + + installPosthogStub(); + await Future.delayed(const Duration(milliseconds: 1500)); + + expect(capturedConfig, isNotNull); + }); + + test( + 'keeps retrying while posthog is present but uninitialized, then ' + 'applies once init declares the mask provider', () async { + final stub = installPosthogStub(loaded: false, declaresMaskProvider: false); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + await Future.delayed(const Duration(milliseconds: 600)); + // present-but-uninitialized must not be classified as not-opted-in + expect(capturedConfig, isNull); + + final canvasCapture = JSObject()..setProperty('maskRegionsFn'.toJS, null); + final sessionRecording = JSObject() + ..setProperty('canvasCapture'.toJS, canvasCapture); + stub + .getProperty('config'.toJS) + .setProperty('session_recording'.toJS, sessionRecording); + stub.setProperty('__loaded'.toJS, true.toJS); + + await Future.delayed(const Duration(milliseconds: 2500)); + expect(capturedConfig, isNotNull); + }); + + test('an exception during one retry tick does not kill the chain', () async { + web.window.setProperty('posthog'.toJS, null); + capturedConfig = null; + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + final stub = installPosthogStub(); + var setConfigCalls = 0; + stub.setProperty( + 'set_config'.toJS, + ((JSObject cfg) { + setConfigCalls++; + if (setConfigCalls == 1) { + throw StateError('stub failure'); + } + capturedConfig = cfg; + }).toJS, + ); + + await Future.delayed(const Duration(milliseconds: 2500)); + expect(setConfigCalls, 2); + expect(capturedConfig, isNotNull); + }); + + test("a second register cancels the predecessor's retry chain", () async { + web.window.setProperty('posthog'.toJS, null); + capturedConfig = null; + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + final stub = installPosthogStub(recordingStarted: true); + var setConfigCalls = 0; + stub.setProperty( + 'set_config'.toJS, + ((JSObject cfg) { + setConfigCalls++; + capturedConfig = cfg; + }).toJS, + ); + + await Future.delayed(const Duration(milliseconds: 1500)); + + expect(setConfigCalls, 1); + expect(stopRecordingCalls, 1); + expect(capturedConfig, isNotNull); + }); + + test('retries the full apply when the restart throws on first register', + () async { + final stub = installPosthogStub(recordingStarted: true); + var stopAttempts = 0; + stub.setProperty( + 'stopSessionRecording'.toJS, + (() { + stopAttempts++; + if (stopAttempts == 1) { + throw StateError('stub stop failure'); + } + }).toJS, + ); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + expect(startRecordingCalls, 0); + + await Future.delayed(const Duration(milliseconds: 600)); + + expect(stopAttempts, 2); + expect(startRecordingCalls, 1); + expect(capturedConfig, isNotNull); + }); + + test('backs off when the apply keeps throwing', () async { + final stub = installPosthogStub(); + var attempts = 0; + void failingSetConfig(JSObject cfg) { + attempts++; + throw StateError('stub failure'); + } + + stub.setProperty('set_config'.toJS, failingSetConfig.toJS); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + await Future.delayed(const Duration(milliseconds: 2200)); + + // the doubling chain (250, 500, 1000, 2000ms) allows ~4 attempts in this + // window; fixed-250ms retries would reach ~9 + expect(attempts, greaterThanOrEqualTo(3)); + expect(attempts, lessThanOrEqualTo(5)); + }); + + test('finishes the restart when stop succeeds but start throws', () async { + final stub = installPosthogStub(recordingStarted: true); + var startAttempts = 0; + stub.setProperty( + 'startSessionRecording'.toJS, + (() { + startAttempts++; + if (startAttempts == 1) { + throw StateError('stub start failure'); + } + }).toJS, + ); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + // the stub now reports the recording as stopped, so the retry must not + // skip the restart block + expect(stopRecordingCalls, 1); + expect(startAttempts, 1); + + await Future.delayed(const Duration(milliseconds: 600)); + + expect(startAttempts, 2); + expect(stopRecordingCalls, 1); + expect(capturedConfig, isNotNull); + }); + + test('restarts an in-flight recording so the new config applies', () { + installPosthogStub(recordingStarted: true); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + test('does not restart recording when none is in flight', () { + installPosthogStub(); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(stopRecordingCalls, 0); + expect(startRecordingCalls, 0); + }); + + test('warns once but still registers when posthog-js is too old', () { + installPosthogStub(version: '1.399.2'); + WebCanvasMaskProvider.debugMinPosthogJsVersionOverride = '1.407.0'; + final warns = interceptWarns(); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(warns(), 1); + expect(capturedConfig, isNotNull); + }); + + test('does not warn when posthog-js meets the minimum', () { + installPosthogStub(version: '1.407.0'); + WebCanvasMaskProvider.debugMinPosthogJsVersionOverride = '1.407.0'; + final warns = interceptWarns(); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(warns(), 0); + expect(capturedConfig, isNotNull); + }); + + test('does not warn when posthog-js exposes no version', () { + installPosthogStub(); + WebCanvasMaskProvider.debugMinPosthogJsVersionOverride = '1.407.0'; + final warns = interceptWarns(); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(warns(), 0); + expect(capturedConfig, isNotNull); + }); + + test('does not warn on an unparseable version', () { + installPosthogStub(version: 'not-a-version'); + WebCanvasMaskProvider.debugMinPosthogJsVersionOverride = '1.407.0'; + final warns = interceptWarns(); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(warns(), 0); + expect(capturedConfig, isNotNull); + }); + + test('warns at most once across repeated applies', () { + installPosthogStub(version: '1.399.2'); + WebCanvasMaskProvider.debugMinPosthogJsVersionOverride = '1.407.0'; + final warns = interceptWarns(); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(warns(), 1); + }); + + testWidgets('warns about an old posthog-js on the mount-triggered apply', + (tester) async { + installPosthogStub(declaresMaskProvider: false, version: '1.399.2'); + WebCanvasMaskProvider.debugMinPosthogJsVersionOverride = '1.407.0'; + final warns = interceptWarns(); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + expect(warns(), 0); + + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + + expect(warns(), 1); + expect(capturedConfig, isNotNull); + }); +}