diff --git a/docs/releases/compatibility.md b/docs/releases/compatibility.md index 384f515..9655f6c 100644 --- a/docs/releases/compatibility.md +++ b/docs/releases/compatibility.md @@ -2,6 +2,7 @@ | Adapter | Adapter version | Native runtime | | --- | --- | --- | +| Flutter `tugboat` | 0.10.0 | Android `capture-runtime` `0.1.0`; Apple `TugboatCaptureRuntime` `0.1.1`; Device Farm launch inputs owned by the SDK; `hidden` + `paused` coalesce into one `app_backgrounded`. | | Flutter `tugboat` | 0.9.0 | Android `capture-runtime` `0.1.0`; Apple `TugboatCaptureRuntime` `0.1.1`; mode-free capture API with additive evidence capabilities. | | Flutter `tugboat` | 0.8.17 | Android `capture-runtime` `0.1.0`; Apple `TugboatCaptureRuntime` `0.1.1`; blank iOS engine captures retry with explicit hierarchy coverage. | | Apple `TugboatCaptureRuntime` | 0.1.1 | Rejects transparent and near-white captures and validates explicit view-hierarchy capture before encoding. | diff --git a/sdks/flutter/packages/tugboat/CHANGELOG.md b/sdks/flutter/packages/tugboat/CHANGELOG.md index 3e2a219..60c527d 100644 --- a/sdks/flutter/packages/tugboat/CHANGELOG.md +++ b/sdks/flutter/packages/tugboat/CHANGELOG.md @@ -1,3 +1,22 @@ +## 0.10.0 + +### Added + +- Device Farm launch inputs owned by the SDK: `TugboatLaunchOptions` + reads Android Intent extras (`tugboat_emit_scene_inventory`, + `tugboat_accept_action_context`, `tugboat_collector_base_url`) and iOS + process environment (`TUGBOAT_EMIT_SCENE_INVENTORY`, + `TUGBOAT_ACCEPT_ACTION_CONTEXT`, `TUGBOAT_COLLECTOR_BASE_URL`) through the + `tugboat/launch` plugin channel, so host apps need no native code. +- `TugboatReplayConfig.withDeviceFarmOverrides()`: additive merge of launch + capabilities plus a release-guarded, local-only collector URL override + (`resolveTugboatCollectorBaseUrl`). Hosts collapse to a single call. + +### Fixed + +- Coalesce the back-to-back `hidden` + `paused` lifecycle callbacks into a + single `app_backgrounded` event instead of emitting one per state. + ## 0.9.0 ### Changed diff --git a/sdks/flutter/packages/tugboat/android/src/main/kotlin/com/tugboat/flutter/TugboatPlugin.kt b/sdks/flutter/packages/tugboat/android/src/main/kotlin/com/tugboat/flutter/TugboatPlugin.kt index a4d9062..17dc139 100644 --- a/sdks/flutter/packages/tugboat/android/src/main/kotlin/com/tugboat/flutter/TugboatPlugin.kt +++ b/sdks/flutter/packages/tugboat/android/src/main/kotlin/com/tugboat/flutter/TugboatPlugin.kt @@ -1,6 +1,7 @@ package com.tugboat.flutter import android.app.Activity +import android.os.Bundle import android.os.Handler import android.os.Looper import android.view.View @@ -18,6 +19,15 @@ import io.flutter.embedding.android.FlutterView import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.MethodChannel + +/// Device Farm launch extras read from the host `Intent`. +/// +/// Natives pass raw strings through; all `1`/`true`/`yes` normalization and +/// local-URL validation lives in `TugboatLaunchParsers` on the Dart side. +const val TUGBOAT_EMIT_SCENE_INVENTORY = "tugboat_emit_scene_inventory" +const val TUGBOAT_ACCEPT_ACTION_CONTEXT = "tugboat_accept_action_context" +const val TUGBOAT_COLLECTOR_BASE_URL = "tugboat_collector_base_url" class TugboatPlugin : FlutterPlugin, @@ -26,13 +36,25 @@ class TugboatPlugin : private var runtime: CaptureRuntime? = null private var activity: Activity? = null private val mainHandler = Handler(Looper.getMainLooper()) + private var launchChannel: MethodChannel? = null override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { runtime = CaptureRuntime() NativeCaptureHostApi.setUp(binding.binaryMessenger, this) + launchChannel = MethodChannel(binding.binaryMessenger, "tugboat/launch").also { channel -> + channel.setMethodCallHandler { call, result -> + if (call.method == "getLaunchOptions") { + result.success(launchOptions()) + } else { + result.notImplemented() + } + } + } } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + launchChannel?.setMethodCallHandler(null) + launchChannel = null NativeCaptureHostApi.setUp(binding.binaryMessenger, null) runtime?.dispose() runtime = null @@ -92,6 +114,22 @@ class TugboatPlugin : return findFlutterView(root) } + private fun launchOptions(): Map { + val intent = activity?.intent + val extras = intent?.extras + return mapOf( + "emitSceneInventory" to rawExtra(extras, TUGBOAT_EMIT_SCENE_INVENTORY), + "acceptActionContext" to rawExtra(extras, TUGBOAT_ACCEPT_ACTION_CONTEXT), + "collectorBaseUrl" to ( + intent?.getStringExtra(TUGBOAT_COLLECTOR_BASE_URL) + ?: rawExtra(extras, TUGBOAT_COLLECTOR_BASE_URL) + ), + ) + } + + private fun rawExtra(extras: Bundle?, name: String): String? = + extras?.get(name)?.toString() + private fun findFlutterView(view: View): View? { if (view is FlutterView) return view if (view is ViewGroup) { diff --git a/sdks/flutter/packages/tugboat/example/pubspec.yaml b/sdks/flutter/packages/tugboat/example/pubspec.yaml index 70e03d8..b3d4414 100644 --- a/sdks/flutter/packages/tugboat/example/pubspec.yaml +++ b/sdks/flutter/packages/tugboat/example/pubspec.yaml @@ -32,7 +32,7 @@ resolution: workspace dependencies: flutter: sdk: flutter - tugboat: ^0.9.0 + tugboat: ^0.10.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. diff --git a/sdks/flutter/packages/tugboat/ios/Classes/TugboatPlugin.swift b/sdks/flutter/packages/tugboat/ios/Classes/TugboatPlugin.swift index 5512e94..dafe235 100644 --- a/sdks/flutter/packages/tugboat/ios/Classes/TugboatPlugin.swift +++ b/sdks/flutter/packages/tugboat/ios/Classes/TugboatPlugin.swift @@ -6,6 +6,7 @@ public class TugboatPlugin: NSObject, FlutterPlugin, NativeCaptureHostApi { private var engineRuntime: CaptureRuntime? private var hierarchyRuntime: CaptureRuntime? private weak var registrar: FlutterPluginRegistrar? + private var launchChannel: FlutterMethodChannel? private let callbackQueue = DispatchQueue.main private let stateLock = NSLock() private var disposed = false @@ -14,9 +15,49 @@ public class TugboatPlugin: NSObject, FlutterPlugin, NativeCaptureHostApi { let instance = TugboatPlugin() instance.registrar = registrar instance.engineRuntime = CaptureRuntime() + // Device Farm launch inputs from the runner's process environment. + // Raw strings pass through; Dart-side `TugboatLaunchParsers` normalizes. + let launchChannel = FlutterMethodChannel( + name: "tugboat/launch", + binaryMessenger: registrar.messenger() + ) + instance.launchChannel = launchChannel + launchChannel.setMethodCallHandler { call, result in + guard call.method == "getLaunchOptions" else { + result(FlutterMethodNotImplemented) + return + } + let environment = ProcessInfo.processInfo.environment + result([ + "emitSceneInventory": Self.launchValue( + environment, + key: "TUGBOAT_EMIT_SCENE_INVENTORY" + ), + "acceptActionContext": Self.launchValue( + environment, + key: "TUGBOAT_ACCEPT_ACTION_CONTEXT" + ), + "collectorBaseUrl": Self.launchValue( + environment, + key: "TUGBOAT_COLLECTOR_BASE_URL" + ), + ]) + } NativeCaptureHostApiSetup.setUp(binaryMessenger: registrar.messenger(), api: instance) } + /// Reads one raw launch value. Absent keys encode as `NSNull` (Dart reads + /// both as off); `TugboatLaunchParsers` owns all normalization. + private static func launchValue( + _ environment: [String: String], + key: String + ) -> Any { + if let value = environment[key] { + return value + } + return NSNull() + } + func getCapabilities() throws -> NativeCaptureCapabilities { guard let runtime = engineCaptureRuntime() else { return NativeCaptureCapabilities( diff --git a/sdks/flutter/packages/tugboat/lib/src/collector_config.dart b/sdks/flutter/packages/tugboat/lib/src/collector_config.dart index 85d7ad5..4d19ba6 100644 --- a/sdks/flutter/packages/tugboat/lib/src/collector_config.dart +++ b/sdks/flutter/packages/tugboat/lib/src/collector_config.dart @@ -205,4 +205,22 @@ class TugboatCollectorConfig { maxPendingFrames: maxPendingFrames, ); } + + /// Returns a copy pointed at [baseUrl] (Device Farm local override). + TugboatCollectorConfig withBaseUrl(String baseUrl) { + return TugboatCollectorConfig( + baseUrl: baseUrl, + apiKey: apiKey, + userId: userId, + appInfo: appInfo, + deviceInfo: deviceInfo, + ipInfo: ipInfo, + locale: locale, + eventBatchSize: eventBatchSize, + eventFlushInterval: eventFlushInterval, + maxPendingBatches: maxPendingBatches, + maxPendingEvents: maxPendingEvents, + maxPendingFrames: maxPendingFrames, + ); + } } diff --git a/sdks/flutter/packages/tugboat/lib/src/controller.dart b/sdks/flutter/packages/tugboat/lib/src/controller.dart index d7b557c..5237ae5 100644 --- a/sdks/flutter/packages/tugboat/lib/src/controller.dart +++ b/sdks/flutter/packages/tugboat/lib/src/controller.dart @@ -1090,6 +1090,8 @@ class TugboatReplayController extends ChangeNotifier { bool _skipCapture = false; bool _captureLifecycleActive = true; int _captureLifecycleEpoch = 0; + AppLifecycleState? _lastLifecycleState; + String? _lastLifecycleEventType; int _routeEpoch = 0; final Map _activeRouteCaptures = {}; @@ -1655,6 +1657,8 @@ class TugboatReplayController extends ChangeNotifier { _clearReleasedInteractions(reason: InteractionRejectionReason.sessionEnd); _captureLifecycleActive = true; _captureLifecycleEpoch++; + _lastLifecycleState = null; + _lastLifecycleEventType = null; _endSessionFuture = null; _clock ..reset() @@ -5151,6 +5155,17 @@ class TugboatReplayController extends ChangeNotifier { } void recordAppLifecycleState(AppLifecycleState state) { + final eventType = _appLifecycleEventType(state); + // Flutter emits hidden + paused back-to-back on every background + // transition (inactive -> hidden -> paused). Both map to + // app_backgrounded, so without dedup each background produces two + // identical events at the same atMs (see pmkit.raw_events). Drop exact + // repeats and consecutive same-type events; a new event is only emitted + // on an effective transition (e.g. backgrounded -> foregrounded -> + // backgrounded still emits twice, correctly). + final isDuplicate = + state == _lastLifecycleState || + eventType == _lastLifecycleEventType; switch (state) { case AppLifecycleState.paused: case AppLifecycleState.hidden: @@ -5186,11 +5201,17 @@ class TugboatReplayController extends ChangeNotifier { case AppLifecycleState.inactive: break; } + _lastLifecycleState = state; + if (isDuplicate) { + _lastLifecycleEventType = eventType; + return; + } + _lastLifecycleEventType = eventType; _addEvent( TugboatEvent( id: _nextId('event'), atMs: atMs, - type: _appLifecycleEventType(state), + type: eventType, data: {'state': state.name}, ), ); diff --git a/sdks/flutter/packages/tugboat/lib/src/launch_options.dart b/sdks/flutter/packages/tugboat/lib/src/launch_options.dart new file mode 100644 index 0000000..5dd874c --- /dev/null +++ b/sdks/flutter/packages/tugboat/lib/src/launch_options.dart @@ -0,0 +1,191 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'replay_config.dart'; + +/// Device Farm launch inputs for Tugboat capture. +/// +/// The SDK reads these at runtime from the host platform so host apps need +/// no native code of their own: +/// +/// * Android: `Intent` extras `tugboat_emit_scene_inventory`, +/// `tugboat_accept_action_context`, `tugboat_collector_base_url` +/// (e.g. `adb shell am start ... -e tugboat_emit_scene_inventory 1`). +/// * iOS: process environment `TUGBOAT_EMIT_SCENE_INVENTORY`, +/// `TUGBOAT_ACCEPT_ACTION_CONTEXT`, `TUGBOAT_COLLECTOR_BASE_URL` +/// (set by the XCUITest/Device Farm runner). +/// +/// Values `1`, `true`, and `yes` (case-insensitive) enable a capability. +/// Everything else — including absent — means off. The collector URL is +/// accepted only for local `http` hosts; see +/// [TugboatLaunchParsers.parseLocalCollectorUrl]. Release builds always use +/// the configured production collector; the override never replaces it. +class TugboatLaunchOptions { + const TugboatLaunchOptions({ + this.emitSceneInventory = false, + this.acceptActionContext = false, + this.collectorBaseUrl, + }); + + static const MethodChannel channel = MethodChannel('tugboat/launch'); + + static const String keyEmitSceneInventory = 'emitSceneInventory'; + static const String keyAcceptActionContext = 'acceptActionContext'; + static const String keyCollectorBaseUrl = 'collectorBaseUrl'; + + final bool emitSceneInventory; + final bool acceptActionContext; + + /// Raw (unvalidated) collector URL from the launch environment, if any. + /// + /// Validate with [TugboatLaunchParsers.parseLocalCollectorUrl] before use. + final String? collectorBaseUrl; + + bool get captureRequested => emitSceneInventory || acceptActionContext; + + /// Reads launch inputs from the host platform. Never throws: a missing + /// plugin, platform error, or unexpected payload yields defaults (off). + /// + /// Pass [channel] in tests to avoid touching the real binary messenger. + static Future fromPlatform({ + MethodChannel? channel, + }) async { + try { + final values = await (channel ?? TugboatLaunchOptions.channel) + .invokeMapMethod('getLaunchOptions'); + return TugboatLaunchOptions.fromMap(values ?? const {}); + } on MissingPluginException { + return const TugboatLaunchOptions(); + } on PlatformException { + return const TugboatLaunchOptions(); + } + } + + factory TugboatLaunchOptions.fromMap(Map values) { + final rawBaseUrl = values[keyCollectorBaseUrl] as String?; + return TugboatLaunchOptions( + emitSceneInventory: TugboatLaunchParsers.parseBool( + values[keyEmitSceneInventory], + ), + acceptActionContext: TugboatLaunchParsers.parseBool( + values[keyAcceptActionContext], + ), + collectorBaseUrl: rawBaseUrl?.trim().isEmpty == true + ? null + : rawBaseUrl?.trim(), + ); + } + + Map toJson() => { + 'captureRequested': captureRequested, + 'emitSceneInventory': emitSceneInventory, + 'acceptActionContext': acceptActionContext, + }; +} + +/// Pure parsers for launch inputs. Single source of truth so Android, +/// iOS, and Dart never drift (natives pass raw strings through). +abstract final class TugboatLaunchParsers { + TugboatLaunchParsers._(); + + static const localHosts = {'127.0.0.1', 'localhost', '10.0.2.2'}; + + static bool parseBool(Object? value) { + if (value is bool) return value; + final normalized = value?.toString().trim().toLowerCase(); + return normalized == '1' || normalized == 'true' || normalized == 'yes'; + } + + /// Returns [value] only when it is a local `http` collector endpoint. + /// + /// Rejects `https`, user-info, queries, fragments, and non-root paths so a + /// farmed launch can never redirect evidence to an arbitrary host. + static String? parseLocalCollectorUrl(String? value) { + final normalized = value?.trim(); + if (normalized == null || normalized.isEmpty) return null; + final uri = Uri.tryParse(normalized); + if (uri == null || + uri.scheme != 'http' || + uri.userInfo.isNotEmpty || + uri.hasQuery || + uri.hasFragment || + (uri.path.isNotEmpty && uri.path != '/')) { + return null; + } + return localHosts.contains(uri.host.toLowerCase()) ? normalized : null; + } +} + +/// Resolves the effective collector base URL for one app launch. +/// +/// Precedence: validated runtime (Device Farm) URL > validated build-time +/// (`--dart-define`) URL > [localFallbackBaseUrl] (e.g. `.env → local` +/// mapping, already validated or null) > [productionBaseUrl]. +/// Release builds always return [productionBaseUrl]. +String resolveTugboatCollectorBaseUrl({ + required bool isRelease, + required String productionBaseUrl, + String? runtimeBaseUrl, + String? buildBaseUrl, + String? localFallbackBaseUrl, +}) { + if (isRelease) return productionBaseUrl; + return TugboatLaunchParsers.parseLocalCollectorUrl(runtimeBaseUrl) ?? + TugboatLaunchParsers.parseLocalCollectorUrl(buildBaseUrl) ?? + localFallbackBaseUrl ?? + productionBaseUrl; +} + +/// Device Farm merge for [TugboatReplayConfig]. Additive only: enabling a +/// launch capability never changes masking, limits, transport, or lifecycle +/// behavior beyond what the matching config field already does. +extension TugboatDeviceFarmConfig on TugboatReplayConfig { + /// Returns `this` merged with [launchOptions] (read from the platform when + /// omitted). + /// + /// * `enabled` becomes true when any launch capability is requested. + /// * `emitSceneInventory` / `acceptActionContext` are OR-ed in. + /// * `collector.baseUrl` is replaced only by a validated local URL in + /// non-release builds; release builds keep the configured endpoint. + /// * Emits one `TUGBOAT_LAUNCH` debug line when launch capture is + /// requested so Device Farm log scraping can confirm the merge without + /// leaking tokens or URLs. Pass `logLaunch: false` to silence it. + Future withDeviceFarmOverrides({ + TugboatLaunchOptions? launchOptions, + bool? isRelease, + String? buildCollectorBaseUrl, + String? defaultLocalBaseUrl, + bool logLaunch = true, + }) async { + final launch = launchOptions ?? await TugboatLaunchOptions.fromPlatform(); + final release = isRelease ?? kReleaseMode; + var config = this; + if (launch.captureRequested) { + config = config.copyWith( + enabled: true, + emitSceneInventory: emitSceneInventory || launch.emitSceneInventory, + acceptActionContext: acceptActionContext || launch.acceptActionContext, + ); + } + final currentCollector = config.collector; + if (currentCollector != null) { + final baseUrl = resolveTugboatCollectorBaseUrl( + isRelease: release, + productionBaseUrl: currentCollector.baseUrl, + runtimeBaseUrl: launch.collectorBaseUrl, + buildBaseUrl: buildCollectorBaseUrl, + localFallbackBaseUrl: defaultLocalBaseUrl, + ); + if (baseUrl != currentCollector.baseUrl) { + config = config.copyWith( + collector: currentCollector.withBaseUrl(baseUrl), + ); + } + } + if (logLaunch && launch.captureRequested) { + // No launch token, API key, or URL is included here. + debugPrint('TUGBOAT_LAUNCH ${launch.toJson()}'); + } + return config; + } +} diff --git a/sdks/flutter/packages/tugboat/lib/src/sdk_version.dart b/sdks/flutter/packages/tugboat/lib/src/sdk_version.dart index 5f66240..6e58833 100644 --- a/sdks/flutter/packages/tugboat/lib/src/sdk_version.dart +++ b/sdks/flutter/packages/tugboat/lib/src/sdk_version.dart @@ -1,3 +1,3 @@ // Keep this in sync with this package's pubspec.yaml. The SDK version test // reads pubspec.yaml directly so release bumps fail fast if this drifts. -const tugboatSdkVersion = '0.9.0'; +const tugboatSdkVersion = '0.10.0'; diff --git a/sdks/flutter/packages/tugboat/lib/tugboat.dart b/sdks/flutter/packages/tugboat/lib/tugboat.dart index bf30869..39e9fd8 100644 --- a/sdks/flutter/packages/tugboat/lib/tugboat.dart +++ b/sdks/flutter/packages/tugboat/lib/tugboat.dart @@ -58,6 +58,12 @@ export 'src/replay_config.dart' TugboatViewportSemanticPolicy, TugboatScreenshotBudgetConfig, resolveViewportSemanticPolicy; +export 'src/launch_options.dart' + show + TugboatLaunchOptions, + TugboatLaunchParsers, + TugboatDeviceFarmConfig, + resolveTugboatCollectorBaseUrl; export 'src/screenshot_capture_backend.dart' show TugboatScreenshotCaptureBackend; export 'src/tugboat.dart'; diff --git a/sdks/flutter/packages/tugboat/pubspec.yaml b/sdks/flutter/packages/tugboat/pubspec.yaml index e84be13..a005e7f 100644 --- a/sdks/flutter/packages/tugboat/pubspec.yaml +++ b/sdks/flutter/packages/tugboat/pubspec.yaml @@ -1,7 +1,7 @@ name: tugboat description: >- Screenshot-based session replay with compact interaction anchors for Tugboat. -version: 0.9.0 +version: 0.10.0 repository: https://github.com/blendto/tugboat-flutter issue_tracker: https://github.com/blendto/tugboat-flutter/issues homepage: https://github.com/blendto/tugboat-flutter diff --git a/sdks/flutter/packages/tugboat/test/launch_options_test.dart b/sdks/flutter/packages/tugboat/test/launch_options_test.dart new file mode 100644 index 0000000..13cf84c --- /dev/null +++ b/sdks/flutter/packages/tugboat/test/launch_options_test.dart @@ -0,0 +1,230 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tugboat/src/collector_config.dart'; +import 'package:tugboat/src/launch_options.dart'; +import 'package:tugboat/src/replay_config.dart'; + +TugboatCollectorConfig _collector({String baseUrl = 'https://prod.example'}) { + return TugboatCollectorConfig( + baseUrl: baseUrl, + apiKey: 'key', + appInfo: const TugboatCollectorAppInfo( + name: 'app', + version: '1', + buildNumber: '1', + installationId: 'id', + appId: 'com.example', + ), + deviceInfo: const TugboatCollectorDeviceInfo( + id: 'device', + platform: 'android', + screenSize: TugboatCollectorScreenSize(width: 100, height: 200), + screenDensity: 2, + screenDpi: 320, + screenPixelDensity: 2, + ), + ipInfo: const TugboatCollectorIpInfo(ip: '127.0.0.1'), + locale: const TugboatCollectorLocaleInfo(), + ); +} + +void main() { + test('parseBool accepts 1/true/yes and bools', () { + expect(TugboatLaunchParsers.parseBool(true), isTrue); + expect(TugboatLaunchParsers.parseBool('1'), isTrue); + expect(TugboatLaunchParsers.parseBool(' True '), isTrue); + expect(TugboatLaunchParsers.parseBool('YES'), isTrue); + expect(TugboatLaunchParsers.parseBool(null), isFalse); + expect(TugboatLaunchParsers.parseBool('0'), isFalse); + expect(TugboatLaunchParsers.parseBool('exploration'), isFalse); + }); + + test('fromMap trims and keeps raw collector URL for validation', () { + final options = TugboatLaunchOptions.fromMap({ + 'emitSceneInventory': '1', + 'acceptActionContext': true, + 'collectorBaseUrl': ' http://10.0.2.2:8787 ', + }); + expect(options.emitSceneInventory, isTrue); + expect(options.acceptActionContext, isTrue); + expect(options.captureRequested, isTrue); + expect(options.collectorBaseUrl, 'http://10.0.2.2:8787'); + }); + + test('fromMap defaults to off', () { + const options = TugboatLaunchOptions(); + expect(options.captureRequested, isFalse); + expect(TugboatLaunchOptions.fromMap(const {}).captureRequested, isFalse); + }); + + test('parseLocalCollectorUrl allows only local http hosts', () { + expect( + TugboatLaunchParsers.parseLocalCollectorUrl('http://127.0.0.1:3000'), + 'http://127.0.0.1:3000', + ); + expect( + TugboatLaunchParsers.parseLocalCollectorUrl('http://LOCALHOST:8787'), + 'http://LOCALHOST:8787', + ); + expect( + TugboatLaunchParsers.parseLocalCollectorUrl('https://127.0.0.1:3000'), + isNull, + ); + expect( + TugboatLaunchParsers.parseLocalCollectorUrl( + 'https://collector.example.com', + ), + isNull, + ); + expect( + TugboatLaunchParsers.parseLocalCollectorUrl( + 'http://127.0.0.1:3000/sdk?x=1', + ), + isNull, + ); + expect( + TugboatLaunchParsers.parseLocalCollectorUrl('http://user@127.0.0.1:3000'), + isNull, + ); + }); + + test('resolve prefers runtime over build over fallback', () { + expect( + resolveTugboatCollectorBaseUrl( + isRelease: false, + productionBaseUrl: 'https://prod.example', + runtimeBaseUrl: 'http://localhost:8787', + buildBaseUrl: 'http://127.0.0.1:3000', + localFallbackBaseUrl: 'http://10.0.2.2:3000', + ), + 'http://localhost:8787', + ); + expect( + resolveTugboatCollectorBaseUrl( + isRelease: false, + productionBaseUrl: 'https://prod.example', + buildBaseUrl: 'http://127.0.0.1:3000', + ), + 'http://127.0.0.1:3000', + ); + expect( + resolveTugboatCollectorBaseUrl( + isRelease: false, + productionBaseUrl: 'https://prod.example', + ), + 'https://prod.example', + ); + }); + + test('resolve ignores non-local runtime URLs', () { + expect( + resolveTugboatCollectorBaseUrl( + isRelease: false, + productionBaseUrl: 'https://prod.example', + runtimeBaseUrl: 'https://evil.example', + ), + 'https://prod.example', + ); + }); + + test('resolve ignores overrides in release', () { + expect( + resolveTugboatCollectorBaseUrl( + isRelease: true, + productionBaseUrl: 'https://prod.example', + runtimeBaseUrl: 'http://localhost:8787', + buildBaseUrl: 'http://127.0.0.1:3000', + localFallbackBaseUrl: 'http://10.0.2.2:3000', + ), + 'https://prod.example', + ); + }); + + test( + 'withDeviceFarmOverrides ORs capabilities without touching mask', + () async { + const config = TugboatReplayConfig(enabled: false, collector: null); + final merged = + await TugboatReplayConfig( + enabled: false, + screenshotMaskLevel: config.screenshotMaskLevel, + ).withDeviceFarmOverrides( + launchOptions: const TugboatLaunchOptions(emitSceneInventory: true), + isRelease: true, + logLaunch: false, + ); + expect(merged.enabled, isTrue); + expect(merged.emitSceneInventory, isTrue); + expect(merged.acceptActionContext, isFalse); + expect( + merged.effectiveScreenshotMaskLevel, + config.effectiveScreenshotMaskLevel, + ); + }, + ); + + test('withDeviceFarmOverrides is a no-op without launch input', () async { + final config = TugboatReplayConfig(collector: _collector()); + final merged = await config.withDeviceFarmOverrides( + launchOptions: const TugboatLaunchOptions(), + isRelease: false, + logLaunch: false, + ); + expect(merged.enabled, isFalse); + expect(merged.collector?.baseUrl, 'https://prod.example'); + }); + + test('withDeviceFarmOverrides rewrites collector in debug only', () async { + final debug = await TugboatReplayConfig(collector: _collector()) + .withDeviceFarmOverrides( + launchOptions: const TugboatLaunchOptions( + collectorBaseUrl: 'http://localhost:8787', + ), + isRelease: false, + logLaunch: false, + ); + expect(debug.collector?.baseUrl, 'http://localhost:8787'); + + final release = await TugboatReplayConfig(collector: _collector()) + .withDeviceFarmOverrides( + launchOptions: const TugboatLaunchOptions( + collectorBaseUrl: 'http://localhost:8787', + ), + isRelease: true, + logLaunch: false, + ); + expect(release.collector?.baseUrl, 'https://prod.example'); + }); + + test('fromPlatform decodes the native launch payload', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + const channel = MethodChannel('tugboat/launch'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + expect(call.method, 'getLaunchOptions'); + return { + 'emitSceneInventory': '1', + 'acceptActionContext': true, + 'collectorBaseUrl': 'http://127.0.0.1:3000', + }; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + final options = await TugboatLaunchOptions.fromPlatform(); + expect(options.emitSceneInventory, isTrue); + expect(options.acceptActionContext, isTrue); + expect(options.captureRequested, isTrue); + expect(options.collectorBaseUrl, 'http://127.0.0.1:3000'); + }); + + test('fromPlatform falls back to off without a native plugin', () async { + TestWidgetsFlutterBinding.ensureInitialized(); + const options = TugboatLaunchOptions(); + expect(options.captureRequested, isFalse); + final fromEmpty = TugboatLaunchOptions.fromMap(const {}); + expect(fromEmpty.captureRequested, isFalse); + }); +} diff --git a/sdks/flutter/packages/tugboat/test/tugboat_replay_test.dart b/sdks/flutter/packages/tugboat/test/tugboat_replay_test.dart index e4c6fe3..fcdafff 100644 --- a/sdks/flutter/packages/tugboat/test/tugboat_replay_test.dart +++ b/sdks/flutter/packages/tugboat/test/tugboat_replay_test.dart @@ -183,6 +183,39 @@ void main() { ); }); + testWidgets('hidden plus paused emits a single app_backgrounded', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => + TugboatReplay.wrapApp(config: _testConfig, child: child!), + home: const SizedBox.expand(), + ), + ); + await tester.pump(); + + final session = TugboatReplay.controller!.session!; + final baseline = session.events + .where((event) => event.type == 'app_backgrounded') + .length; + // Flutter delivers hidden + paused back-to-back on every background + // transition; both map to app_backgrounded and must coalesce. + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden); + await tester.pump(); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(); + + expect( + session.events.where((event) => event.type == 'app_backgrounded'), + hasLength(baseline + 1), + ); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(); + }); + testWidgets('captures initial screenshot and tap interaction anchors', ( tester, ) async { diff --git a/sdks/flutter/packages/tugboat_dio/CHANGELOG.md b/sdks/flutter/packages/tugboat_dio/CHANGELOG.md index 3f2e972..c7c13ae 100644 --- a/sdks/flutter/packages/tugboat_dio/CHANGELOG.md +++ b/sdks/flutter/packages/tugboat_dio/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.10.0 + +### Changed + +- Compatibility release for `tugboat` 0.10.0. The Dio + adapter has no runtime behavior change. + ## 0.9.0 ### Changed diff --git a/sdks/flutter/packages/tugboat_dio/pubspec.yaml b/sdks/flutter/packages/tugboat_dio/pubspec.yaml index 01fd219..693451a 100644 --- a/sdks/flutter/packages/tugboat_dio/pubspec.yaml +++ b/sdks/flutter/packages/tugboat_dio/pubspec.yaml @@ -2,7 +2,7 @@ name: tugboat_dio description: >- Dio interceptor that records safe, bounded network evidence into an active Tugboat capture session. -version: 0.9.0 +version: 0.10.0 repository: https://github.com/blendto/tugboat-flutter issue_tracker: https://github.com/blendto/tugboat-flutter/issues homepage: https://github.com/blendto/tugboat-flutter @@ -18,7 +18,7 @@ dependencies: dio: ^5.4.0 flutter: sdk: flutter - tugboat: ^0.9.0 + tugboat: ^0.10.0 dev_dependencies: flutter_lints: ^5.0.0