diff --git a/.changeset/canvas-masking-web.md b/.changeset/canvas-masking-web.md index a608164b..8242424c 100644 --- a/.changeset/canvas-masking-web.md +++ b/.changeset/canvas-masking-web.md @@ -2,4 +2,4 @@ "posthog_flutter": minor --- -Add session replay canvas masking on Flutter web: `maskAllTexts`, `maskAllImages`, `PostHogMaskWidget`, and obscured text fields now apply to the CanvasKit canvas (requires posthog-js 1.408.0+; enable by declaring `session_recording.canvasCapture.maskRegionsFn` in `posthog.init`) +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/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/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_widget.dart b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart index 74fd339e..826135ad 100644 --- a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart +++ b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart @@ -1,14 +1,27 @@ 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:** this has no effect unless canvas masking is enabled, since -/// the canvas is masked by posthog-js rather than by this plugin. Declare +/// **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. Frames captured before that first +/// mount are recorded unmasked; to cover the window between `posthog.init` and +/// Flutter booting, declare /// `session_recording: { canvasCapture: { maskRegionsFn: () => null } }` -/// in your `posthog.init` call to turn it on. iOS and Android need no setup. +/// 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; @@ -27,10 +40,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_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart index 461a04ff..88d21e0c 100644 --- a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -6,7 +6,8 @@ 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 View; +import 'package:flutter/widgets.dart' + show BuildContext, ModalRoute, Navigator, View; import 'package:web/web.dart' as web; import '../../posthog_config.dart'; @@ -33,15 +34,25 @@ const _semanticsBlockSelector = 'flt-semantics-host'; // (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 by declaring `maskRegionsFn` in its `posthog.init` -/// call; declaring it as `() => null` also covers the frames captured before -/// this provider takes over. Without that key nothing is registered, so -/// recording is left exactly as posthog-js configured 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. @@ -49,7 +60,9 @@ class WebCanvasMaskProvider { WebCanvasMaskProvider(this._config); static WebCanvasMaskProvider? _active; + static bool _maskWidgetSeen = false; static bool _warnedOldPosthogJs = false; + static final Set _mountedMaskWidgets = {}; @visibleForTesting static String? debugMinPosthogJsVersionOverride; @@ -59,10 +72,56 @@ class WebCanvasMaskProvider { @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; @@ -75,7 +134,11 @@ class WebCanvasMaskProvider { 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 @@ -89,11 +152,12 @@ class WebCanvasMaskProvider { // 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); - _registerUnsafe(); + _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 @@ -102,9 +166,27 @@ class WebCanvasMaskProvider { } } - void _registerUnsafe() { - final ph = posthog; - if (ph != null && _tryApplyConfig(ph)) { + 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 @@ -122,6 +204,11 @@ class WebCanvasMaskProvider { // 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 @@ -130,8 +217,8 @@ class WebCanvasMaskProvider { ? const Duration(seconds: 4) : doubled; try { - final current = posthog; - if (current != null && _tryApplyConfig(current)) { + if (_apply() != _ApplyResult.posthogNotReady) { + _polling = false; return; } } catch (e) { @@ -163,9 +250,13 @@ class WebCanvasMaskProvider { return loaded.isA() && (loaded as JSBoolean).toDart; } - bool _tryApplyConfig(PostHog ph) { - if (!_isInitialized(ph)) { - return false; + _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 — @@ -183,11 +274,12 @@ class WebCanvasMaskProvider { _objectAssign(canvasCapture, existingCanvasCapture as JSObject); } // the app opts into canvas masking by declaring maskRegionsFn in - // posthog.init — registering regardless would restart an in-flight - // recording and drop the semantics tree for apps that never asked - if (!canvasCapture.has('maskRegionsFn')) { + // 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 true; + return _ApplyResult.notOptedIn; } _warnIfPosthogJsTooOld(ph); _ensureFrameCounter(); @@ -204,9 +296,12 @@ class WebCanvasMaskProvider { ph.set_config(config); // blockSelector is only read when rrweb's record() starts, so an in-flight - // recording must be restarted. _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. + // 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(); @@ -215,15 +310,18 @@ class WebCanvasMaskProvider { ph.startSessionRecording(); _pendingRestart = false; } - return true; + // 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: maskRegionsFn is not declared in posthog.init, ' - 'so Flutter web canvas masking is off.', + '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) { @@ -238,9 +336,11 @@ class WebCanvasMaskProvider { (captureCanvas as JSObject).getProperty('recordCanvas'.toJS); if (recordCanvas.isA() && (recordCanvas as JSBoolean).toDart) { web.console.warn( - 'PostHog: canvas session recording is enabled but maskRegionsFn ' - 'is missing from posthog.init, so text painted by Flutter is recorded ' - 'unmasked. See the posthog_flutter CHANGELOG for the snippet.' + '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, ); } @@ -343,6 +443,18 @@ class WebCanvasMaskProvider { } _consecutiveWalkFailures = 0; + 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; + } + // our rects always describe PostHogWidget's tree — shipping them with a // different flutter-view's canvas would record that view unmasked if (!_isOwnViewCanvas(host)) { @@ -431,6 +543,45 @@ class WebCanvasMaskProvider { 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() { @@ -467,9 +618,7 @@ class WebCanvasMaskProvider { 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, or remove maskRegionsFn ' - 'from your posthog.init to disable canvas masking (the canvas ' - 'is then recorded unmasked).' + 'Wrap your app in PostHogWidget.' .toJS, ); } diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index f665b9b3..cfc7d594 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -4,7 +4,7 @@ library; import 'dart:js_interop'; import 'dart:js_interop_unsafe'; -import 'package:flutter/widgets.dart'; +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'; @@ -14,6 +14,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); JSObject? capturedConfig; + var setConfigCalls = 0; var stopRecordingCalls = 0; var startRecordingCalls = 0; @@ -26,6 +27,7 @@ void main() { String? version, }) { capturedConfig = null; + setConfigCalls = 0; stopRecordingCalls = 0; startRecordingCalls = 0; final stub = JSObject(); @@ -52,6 +54,7 @@ void main() { 'set_config'.toJS, ((JSObject cfg) { capturedConfig = cfg; + setConfigCalls++; }).toJS, ); var recordingState = recordingStarted; @@ -123,6 +126,389 @@ void main() { 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(); @@ -248,6 +634,7 @@ void main() { 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 { @@ -273,6 +660,7 @@ void main() { final canvas = web.document.createElement('canvas'); flutterView.appendChild(canvas); web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; try { final regionsFn = capturedSessionRecording() .getProperty('canvasCapture'.toJS) @@ -738,4 +1126,19 @@ void main() { 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); + }); }