diff --git a/docs/design/capture-and-fingerprint.md b/docs/design/capture-and-fingerprint.md index b60e2bb..4b55031 100644 --- a/docs/design/capture-and-fingerprint.md +++ b/docs/design/capture-and-fingerprint.md @@ -15,12 +15,12 @@ arbitrary visible text as structural identity. - State and target identity are deterministic within one build and fingerprint schema version. -- Exploration and production profiles use the same fingerprint algorithm. +- Optional evidence capabilities use the same fingerprint algorithm. - Locale is emitted as session and event evidence but never enters identity. - Developer tags strengthen matching but are optional. - Screenshots remain visual evidence and are masked before leaving the app. - Capture failures and sink failures must not interrupt the host app. -- Dormant and disabled modes return the host child unchanged. +- Disabled lifecycle configuration returns the host child unchanged. Cross-build stability is not promised. Consumers should namespace identity by build metadata and `fingerprintSchemaVersion`; remapping identities across app @@ -46,21 +46,20 @@ NavigatorObserver + global/local pointer input + scroll notifications ``` `TugboatReplay.wrapApp` installs the controller, repaint boundary, input -capture, scroll listener, and lifecycle observer only for an active profile. +capture, scroll listener, and lifecycle observer only when capture is enabled. `TugboatReplay.navigatorObserver` supplies route changes and navigator context. Both are needed for complete capture. -### Profiles and activation +### Lifecycle and capabilities -| Profile | Implemented behavior | -| --- | --- | -| `dormant` | Always-mounted lightweight gate; no pointer/screenshot/sink machinery until `activate` | -| `exploration` | full interaction capture, scene inventory, and optional emitted semantic maps | -| `productionLean` | interaction capture, no scene-inventory events, production screenshot masking by default | +`TugboatReplayConfig.enabled` controls the capture lifecycle. Scene inventory, +semantic-map, diagnostic, and action-context evidence use separate additive +capabilities. All optional capabilities are false by default. The default +screenshot mask is always `allTextAndMedia`. `TugboatReplay.disabled = true` is a global kill switch. It deactivates the current controller and keeps future calls to `wrapApp` inert. Runtime -`activate(activationRequestId:, profile:)` notifies the mounted gate without +`activate(activationRequestId:)` notifies the mounted gate without requiring a host rebuild. `deactivate()` tears capture down through the same gate. Pause/hidden flush pending delivery; detach ends the session once. @@ -261,10 +260,9 @@ the SDK collects mask rectangles using the shared anchor resolver and applies them as opaque fills on the RGBA buffer inside the encode isolate (avoiding a second full-size GPU raster). -The default mask policy is profile-dependent: - -- `exploration`: explicit `TugboatSensitive` subtrees only; -- `productionLean`: all rendered text, editable inputs, and images. +The default mask policy covers all rendered text, editable inputs, and images. +Hosts can select a stricter or narrower explicit mask policy. Evidence +capabilities never change the mask. The public mask levels are `explicitOnly`, `allTextAndMedia`, `allText`, `allTextExceptActionable`, and `sensitiveInputsOnly`. @@ -336,19 +334,18 @@ device-tier gates. ## Viewport semantics -`viewportSemanticMode` resolves with the capture profile: +`viewportSemanticMode` resolves with `emitViewportSemanticMap`: | Mode | Engine | Emits map events | Debug logs | | --- | --- | --- | --- | | `off` | no | no | no | -| `tapResolutionOnly` | yes for active profiles | no | no | -| `full` | yes | exploration only | no | -| `fullWithDebugLogs` | yes | exploration only | exploration only | - -Exploration holds a persistent Flutter `SemanticsHandle` when semantics are -enabled. Production uses transient semantics and never emits semantic-map -events, even in `full` modes; it can still build maps locally for tap -resolution. Emitted exploration maps are bounded by +| `tapResolutionOnly` | yes while enabled | no | no | +| `full` | yes | when capability is true | no | +| `fullWithDebugLogs` | yes | when capability is true | when capability is true | + +The SDK holds a persistent Flutter `SemanticsHandle` only while semantic-map +emission is enabled. It can still build maps locally for tap resolution. +Emitted maps are bounded by `viewportSemanticMapMaxNodes` (default `120`) and `viewportSemanticMapMaxBytes` (default `48000`). @@ -412,7 +409,7 @@ Unit thresholds live in `benchmark/screenshot_budget_baseline.dart`. The default rolling budget is 60 ms per 5 s window so eligible captures skip under load sooner now that paint-generation / dHash coalesce replace the old post-capture state-signature short circuit. Record multi-tier device measurements before -enabling more aggressive degradation in production profiles. +enabling more aggressive degradation in normal app capture. ### 3. Deferred capture surfaces diff --git a/docs/integration/collector.md b/docs/integration/collector.md index 73fb452..70e0ba2 100644 --- a/docs/integration/collector.md +++ b/docs/integration/collector.md @@ -14,8 +14,8 @@ Transport failures are isolated from the host app. ## Required app integration -Install the wrapper and navigator observer. Capture is dormant by default, so -select an active profile for startup capture: +Install the wrapper and navigator observer. Capture is disabled by default. +Set `enabled: true` for startup capture, or call `TugboatReplay.activate` later: ```dart MaterialApp( @@ -27,6 +27,10 @@ MaterialApp( ); ``` +The default configuration masks all text and media. Optional inventory, +semantic-map, diagnostic, and action-context evidence stays off until the host +enables each matching capability. + Without the observer, pointer and scroll evidence still works but route-change events and route-backed anchors are incomplete. Without the wrapper no capture controller or transport is installed. @@ -42,7 +46,10 @@ Use the exploration destination for an interactive local run: ```dart const config = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + acceptActionContext: true, explorationCollectorUrl: 'ws://127.0.0.1:7832/sdk', explorationRunId: 'optional-run-id', appInfo: TugboatCollectorAppInfo( @@ -115,7 +122,7 @@ final collector = await TugboatCollectorHost.fromPlatform( ); final config = TugboatReplayConfig( - profile: TugboatCaptureProfile.productionLean, + enabled: true, collector: collector, ); ``` @@ -301,7 +308,10 @@ stamping or clearing a newer session's evidence. ```dart final config = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + acceptActionContext: true, explorationCollectorUrl: 'ws://127.0.0.1:7832/sdk', collector: productionCollectorConfig, ); diff --git a/docs/releases/compatibility.md b/docs/releases/compatibility.md index 72af907..384f515 100644 --- a/docs/releases/compatibility.md +++ b/docs/releases/compatibility.md @@ -2,6 +2,7 @@ | Adapter | Adapter version | Native runtime | | --- | --- | --- | +| 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. | | Flutter `tugboat` | 0.8.16 | Same hosted runtimes as 0.8.15. iOS plugin looks up `registrar.viewController` at runtime so Flutter 3.35 hosts compile. | @@ -9,7 +10,6 @@ | Flutter `tugboat` | 0.8.14 | Android `com.gettugboat.sdk:capture-runtime:0.1.0` from Maven Central. Apple `TugboatCaptureRuntime` 0.1.x compiled from monorepo sources (unpublished CocoaPod is not required). Plugin iOS floor 12; native capture still reports unsupported below iOS 15. | | Flutter `tugboat` | 0.8.13 | Android `capture-runtime` 0.1.x compiled from monorepo sources (hosted Maven is not required). Apple `TugboatCaptureRuntime` 0.1.x compiled from monorepo sources (unpublished CocoaPod is not required). Plugin iOS floor 12; native capture still reports unsupported below iOS 15. | | Flutter `tugboat` | 0.8.12 | none (Flutter `flutterRepaintBoundary` only; session identity stamp patch) | -| Flutter `tugboat` | 0.9.0 (planned) | Android `capture-runtime` 0.1.x and Apple `TugboatCaptureRuntime` 0.1.x | | Apple `TugboatCaptureRuntime` | 0.1.0 | CocoaPods trunk / SwiftPM git tag; iOS 15 live Flutter-layer CPU path | | `@tugboat/react-native` | — | not started | diff --git a/sdks/flutter/packages/tugboat/CHANGELOG.md b/sdks/flutter/packages/tugboat/CHANGELOG.md index 3e83984..3e2a219 100644 --- a/sdks/flutter/packages/tugboat/CHANGELOG.md +++ b/sdks/flutter/packages/tugboat/CHANGELOG.md @@ -1,3 +1,27 @@ +## 0.9.0 + +### Changed + +- Replace `TugboatCaptureProfile` with one privacy-safe capture policy. +- Use `TugboatReplayConfig.enabled` for lifecycle configuration. +- Add explicit, additive capabilities for scene inventory, viewport semantic + maps, capture diagnostics, and external action context. +- Keep `allTextAndMedia` as the default screenshot mask when capabilities are + enabled. +- Make `TugboatParameterPolicy.allowAll` mode-free and remove the duplicate + production-specific parameter policy. +- Remove capture-profile fields from SDK health and sink session context. +- Rename the controller action-window API to `setActionContext` and + `clearActionContext`. + +### Migration + +- Replace `profile: TugboatCaptureProfile.dormant` with `enabled: false`. +- Replace `profile: TugboatCaptureProfile.productionLean` with `enabled: true`. +- Replace exploration mode with `enabled: true` plus only the required + additive capabilities. +- Remove the `profile` argument from `TugboatReplay.activate`. + ## 0.8.17 ### Fixed diff --git a/sdks/flutter/packages/tugboat/README.md b/sdks/flutter/packages/tugboat/README.md index 6c2329f..12b20f4 100644 --- a/sdks/flutter/packages/tugboat/README.md +++ b/sdks/flutter/packages/tugboat/README.md @@ -5,10 +5,17 @@ checkpoints around meaningful interactions, compact structural anchors, route transitions, scrolling evidence, and optional viewport semantic maps. Capture can be sent to the local exploration WebSocket, the HTTP collector, or both. -The current package version is `0.8.16`. Session JSON writers and readers use +The current package version is `0.9.0`. Session JSON writers and readers use schema version `10` only. Structural fingerprints use fingerprint schema version `6`. +## 0.9.0 + +The SDK has one privacy-safe capture behavior. `enabled` controls the capture +lifecycle. Additive capability fields control optional inventory, semantic +map, diagnostic, and action-context evidence. The SDK no longer exports +`TugboatCaptureProfile`. + ## 0.8.16 The iOS plugin compiles on Flutter 3.35. Native CPU capture still loads @@ -131,7 +138,7 @@ events. ```yaml dependencies: - tugboat: ^0.8.16 + tugboat: ^0.9.0 ``` Then import the public barrel: @@ -147,8 +154,8 @@ Install from pub.dev; a GitHub checkout is not required. ```yaml dependencies: - tugboat: ^0.8.16 - tugboat_dio: ^0.8.16 + tugboat: ^0.9.0 + tugboat_dio: ^0.9.0 ``` See the [`tugboat_dio`](https://pub.dev/packages/tugboat_dio) package. @@ -182,11 +189,10 @@ failedCall.complete( ); ``` -Both emit on `stream: evidence` and never inherit exploration `actionId` or UI -anchors. The default parameter policy retains JSON-safe values within hard -limits in every capture profile, including production. `allowAll` is an -exploration-only escape hatch; outside exploration profiles the SDK downgrades -it to names-only at record time. +Both emit on `stream: evidence` and never inherit an active action context or +UI anchors. The default `allowAll` parameter policy retains JSON-safe values +within hard limits. Use `namesOnly`, an allow-list, or a transform when the +host needs a narrower policy. ### Omitting parameter values @@ -249,8 +255,9 @@ The public `package:tugboat/tugboat.dart` barrel no longer exports: ## Minimal integration -Install both the app wrapper and navigator observer. Capture is dormant by -default, so choose an active profile when the app should record immediately: +Install both the app wrapper and navigator observer. Capture is disabled by +default. Set `enabled` when the app should record immediately. Enable optional +evidence capabilities only when the host has authority for that app launch: ```dart MaterialApp( @@ -258,7 +265,10 @@ MaterialApp( builder: (context, child) => TugboatReplay.wrapApp( child: child!, config: const TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + acceptActionContext: true, explorationCollectorUrl: 'ws://127.0.0.1:7832/sdk', viewportSemanticMode: TugboatViewportSemanticMode.full, ), @@ -312,16 +322,16 @@ and publishes one canonical `interaction`. A claimed `route_change` uses that interaction ID as `causeEventId`. Released pointer-up claims apply only through the pointer-up turn. Timer or auth redirects stay `automatic_or_unknown`. -## Capture profiles and runtime state +## Capture lifecycle and optional evidence -`TugboatReplayConfig.profile` controls whether the wrapper installs capture -machinery: +`TugboatReplayConfig.enabled` controls whether the wrapper installs capture +machinery. An enabled SDK records the normal bounded capture stream. The +default screenshot mask is always `allTextAndMedia`. -| Profile | Current behavior | Default screenshot masking | -| --- | --- | --- | -| `dormant` | Lightweight gate mounted; no capture machinery until `activate` | explicit subtrees only, if activated | -| `exploration` | full interaction capture, scene inventories, optional emitted semantic maps | `TugboatSensitive` only | -| `productionLean` | interaction capture and sampled/deduplicated screenshots; no scene-inventory events | all text, editable fields, and images | +Optional evidence is additive. `emitSceneInventory`, +`emitViewportSemanticMap`, `emitCaptureDiagnostics`, and +`acceptActionContext` are false by default. Enabling one capability does not +change masking, limits, transport, or lifecycle behavior. The global kill switch is fully inert: @@ -329,12 +339,12 @@ The global kill switch is fully inert: TugboatReplay.disabled = true; // deactivates and disposes the active controller ``` -Dormant builds can be activated at runtime without rebuilding `MaterialApp`: +Disabled configurations can be activated at runtime without rebuilding +`MaterialApp`: ```dart TugboatReplay.activate( activationRequestId: captureRequestId, - profile: TugboatCaptureProfile.productionLean, ); ``` @@ -383,7 +393,7 @@ but it does not persist events or frames across process restarts. | Field | Default | Purpose | | --- | --- | --- | -| `profile` | `dormant` | capture cost and exploration-only behavior | +| `enabled` | `false` | start the privacy-safe capture lifecycle with the app | | `settleDelay` | 1 second | delay before post-interaction and post-route capture | | `scrollEndCaptureDelay` | zero | optional idle delay before a pointer-linked scroll after-frame; does not block the controller queue | | `interactionClaimWindow` | 1,250 ms | released-tap window for delayed route/modal attribution; `Duration.zero` keeps microtask-only same-turn claims | @@ -394,14 +404,18 @@ but it does not persist events or frames across process restarts. | `captureScrollScreenshots` | `false` | request pressure-droppable visual checkpoints while scrolling; scroll metrics and the scroll-end observation remain independent | | `capturePixelRatio` | `0.75` | requested repaint-boundary screenshot scale; values above `1.0` are supported | | `captureMaxWidth` / `captureMaxHeight` | null | optional output pixel bounds applied before readback while preserving aspect ratio | -| `degradedCaptureScale` | `0.67` | additional scale applied before readback while the screenshot budget is degraded | +| `degradedCaptureScale` | `0.80` | additional scale applied before readback while the screenshot budget is degraded | | `enableGlobalPointerCapture` | `true` | use global pointer routing; `false` uses a local `Listener` | +| `emitSceneInventory` | `false` | allow bounded `scene_inventory` events | +| `emitViewportSemanticMap` | `false` | allow bounded semantic-map events when the semantic mode supports them | +| `emitCaptureDiagnostics` | `false` | allow bounded `capture_diagnostic` events; health counters remain available without it | +| `acceptActionContext` | `false` | allow external action context on captured evidence | | `explorationCollectorUrl` | null | local exploration WebSocket endpoint | | `explorationRunId` | null | optional run correlation ID | | `userId` | null | optional HTTP event user ID | | `appInfo` | null | app metadata used by exploration and as a fallback | | `collector` | null | HTTP collector configuration | -| `screenshotMaskLevel` | profile default | explicit screenshot redaction policy | +| `screenshotMaskLevel` | `allTextAndMedia` | explicit screenshot redaction policy | | `widgetNames` | empty | `Type` to stable-name overrides for canonical paths | | `viewportSemanticMode` | `tapResolutionOnly` | semantic engine and emission mode | | `viewportSemanticMapMaxNodes` | 120 | emitted map node budget | @@ -410,16 +424,16 @@ but it does not persist events or frames across process restarts. | `screenshotBudget` | 60ms / 5s window | degraded-capture skip window / budget | | `screenshotCaptureBackend` | `flutterRepaintBoundary` | pixel source; `nativeCpuExperimental` is opt-in on Android and iOS | -### Resolver and exploration events +### Optional resolver and action-context events -When exploration is active, the controller may emit: +When the matching capabilities are enabled, the controller may emit: | Event | Role | | --- | --- | | `scene_inventory` | Deduped actionable/image inventory for the settled state | | `viewport_semantic_map` | Bounded semantic node map (mode-dependent) | | `scroll_semantic_snapshot` | Semantic snapshot tied to scroll checkpoints | -| `action_window_set` / `action_window_cleared` | CLI exploration action-window fencing | +| `action_window_set` / `action_window_cleared` | external action-context fencing | ## Privacy and payload boundary @@ -479,9 +493,9 @@ Emitted inferred event types currently include: gesture-specific facts under `payload` (omitted for `cancelled`); - lifecycle: `session_start`, `session_identify`, `session_end`; - navigation evidence (`stream: evidence`): `route_change`; -- diagnostics: `capture_diagnostic` (`stream: diagnostic`; exploration profiles only; - `productionLean` updates on-device health counters without session events); -- exploration: `scene_inventory`, `action_window_set`, +- diagnostics: `capture_diagnostic` (`stream: diagnostic`; only when + `emitCaptureDiagnostics` is true; health counters always update); +- optional evidence: `scene_inventory`, `action_window_set`, `action_window_cleared`; - semantic-map modes: `viewport_semantic_map`, `scroll_semantic_snapshot`. @@ -523,8 +537,8 @@ or explicit controller calls. Capture requests are serialized. Each published frame records `requestedBackend` and `resolvedBackend` (closed names: `flutterRepaintBoundary` or `nativeCpuExperimental`). After a native fallback, `resolvedBackend` is Flutter and `fallbackReason` carries the closed token. -These fields are on the frame even in `productionLean`; nested stage timings -remain on exploration `capture_diagnostic` events. Non-interaction +These fields are always on the frame. Nested stage timings remain on optional +`capture_diagnostic` events. Non-interaction requests can coalesce. When the capture boundary has not painted since the last accepted frame, the SDK reuses that frame without GPU readback. Otherwise it uses a small dHash (Hamming distance ≤ 2) to avoid JPEG encoding for @@ -611,11 +625,9 @@ build identity and target fingerprint as the control identity key. Each logical capture request records one privacy-safe resolution in `healthSnapshot().captureDiagnostics` (bounded outcome counts and last -outcome). Exploration profiles also emit a `capture_diagnostic` session event -(`stream: diagnostic`). `productionLean` profiles omit those events from the -session and collector to reduce volume; use on-device health for capture -telemetry in production. Published frames still carry `requestedBackend`, -`resolvedBackend`, and optional `fallbackReason` in every profile. +outcome). The SDK emits a `capture_diagnostic` session event only when +`emitCaptureDiagnostics` is true. Published frames still carry +`requestedBackend`, `resolvedBackend`, and optional `fallbackReason`. Distinct request IDs with the same execution ID (and `coalesced: true`) identify scheduler coalescing when diagnostic events are present. Diagnostics contain only diff --git a/sdks/flutter/packages/tugboat/example/pubspec.yaml b/sdks/flutter/packages/tugboat/example/pubspec.yaml index 3611071..70e03d8 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.8.17 + tugboat: ^0.9.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/lib/src/capture_profile.dart b/sdks/flutter/packages/tugboat/lib/src/capture_profile.dart deleted file mode 100644 index f7644c7..0000000 --- a/sdks/flutter/packages/tugboat/lib/src/capture_profile.dart +++ /dev/null @@ -1,11 +0,0 @@ -/// Controls how much instrumentation the SDK installs in a host app. -enum TugboatCaptureProfile { - /// No capture machinery until [TugboatReplay.activate] is called. - dormant, - - /// Full capture for graphing and deep diagnostics. - exploration, - - /// Fingerprints plus sampled screenshots for real user sessions. - productionLean, -} diff --git a/sdks/flutter/packages/tugboat/lib/src/controller.dart b/sdks/flutter/packages/tugboat/lib/src/controller.dart index 72f7810..d7b557c 100644 --- a/sdks/flutter/packages/tugboat/lib/src/controller.dart +++ b/sdks/flutter/packages/tugboat/lib/src/controller.dart @@ -5,7 +5,6 @@ import 'package:flutter/semantics.dart'; import 'package:flutter/widgets.dart'; import 'anchors.dart'; -import 'capture_profile.dart'; import 'capture_sink.dart'; import 'collector_http_sink.dart'; import 'coordinate_space.dart'; @@ -1018,7 +1017,6 @@ class TugboatReplayController extends ChangeNotifier { appendEvidence: (event) => _addEvent(event, attachActionContext: false), nextEventId: _nextId, nowMs: () => atMs, - profile: () => config.profile, ); } @@ -1547,7 +1545,6 @@ class TugboatReplayController extends ChangeNotifier { TugboatSdkHealth healthSnapshot() { return TugboatSdkHealth( lifecycle: _session == null ? 'dormant' : 'active', - profile: config.profile.name, activationRequestId: activationRequestId, captureSessionId: _session?.id, sinks: TugboatSinkHealth( @@ -1706,7 +1703,6 @@ class TugboatReplayController extends ChangeNotifier { sessionEpoch: sessionEpoch, activationRequestId: activationRequestId, explorationRunId: config.explorationRunId, - profileName: config.profile.name, ); if (config.sinkFactories.isNotEmpty) { final all = [ @@ -1926,8 +1922,8 @@ class TugboatReplayController extends ChangeNotifier { /// request. This deliberately records a taxonomy value rather than the /// underlying exception so replay telemetry never contains app data. /// - /// [productionLean] profiles update [healthSnapshot] counters only. Session - /// and collector output omit `capture_diagnostic` events to reduce volume. + /// By default, diagnostics update [healthSnapshot] counters only. The host + /// must explicitly allow diagnostic event emission. void _recordCaptureDiagnostic(_CaptureResolution resolution) { final outcome = resolution.outcome.wireName; _captureDiagnosticTotal = (_captureDiagnosticTotal + 1).clamp( @@ -1943,7 +1939,7 @@ class TugboatReplayController extends ChangeNotifier { _maxCaptureDiagnosticCount, ); } - if (config.profile == TugboatCaptureProfile.productionLean) return; + if (!config.emitCaptureDiagnostics) return; _addEvent( TugboatEvent( id: _nextId('event'), @@ -2964,8 +2960,7 @@ class TugboatReplayController extends ChangeNotifier { ); final tx = InteractionTransaction(origin: origin, pointerId: pointer); _interactions.register(tx); - if (config.profile == TugboatCaptureProfile.exploration && - isPrimaryPointer) { + if (config.sceneInventoryEmissionEnabled && isPrimaryPointer) { _captureExplorationPreTapEvidence(tx); } if (!_disposed) notifyListeners(); @@ -3223,6 +3218,7 @@ class TugboatReplayController extends ChangeNotifier { InteractionTransaction tx, TugboatPreTapEvidence evidence, ) { + if (!config.emitCaptureDiagnostics) return; _addEvent( TugboatEvent( id: _nextId('event'), @@ -3256,11 +3252,10 @@ class TugboatReplayController extends ChangeNotifier { /// lands on a sibling still matches the recognizer's original target. void _resolveTapEvidence(InteractionTransaction tx, Offset position) { final resolver = _anchorResolver; - if (config.profile == TugboatCaptureProfile.exploration) { + if (config.sceneInventoryEmissionEnabled) { _resolveExplorationTapEvidence(tx, position); return; } - if (config.profile == TugboatCaptureProfile.dormant) return; TugboatSceneInventory? tapInventory; if (resolver != null) { final tapContext = resolver.buildTapContext( @@ -4291,9 +4286,7 @@ class TugboatReplayController extends ChangeNotifier { TugboatTargetAnchor? _resolveScrollableAnchor(Element scrollableElement) { final resolver = _anchorResolver; - if (resolver == null || config.profile == TugboatCaptureProfile.dormant) { - return null; - } + if (resolver == null) return null; return resolver.scrollableAnchorFor( scrollableElement, route: _currentRoute, @@ -5615,7 +5608,10 @@ class TugboatReplayController extends ChangeNotifier { void _maybeEmitSceneInventory({ TugboatViewportSemanticScrollContext? scrollContext, }) { - if (config.profile != TugboatCaptureProfile.exploration) return; + if (!config.sceneInventoryEmissionEnabled && + !config.semanticMapEmissionEnabled) { + return; + } final resolver = _anchorResolver; if (resolver == null || _session == null) return; @@ -5634,25 +5630,24 @@ class TugboatReplayController extends ChangeNotifier { bool emitViewportSemanticMap = true, TugboatViewportSemanticScrollContext? scrollContext, }) { - if (config.profile != TugboatCaptureProfile.exploration) return; - // Always emit raw scene_inventory first (when new). Semantic-map emission - // must not replace or suppress inventory; maps are an exploration companion. - final dedupeKey = [ - inventory.routeKey, - inventory.inventoryHash, - scrollContext?.dedupeKey ?? '', - ].join('|'); - if (_emittedInventories.add(dedupeKey)) { - _addEvent( - TugboatEvent( - id: _nextId('event'), - atMs: atMs, - type: 'scene_inventory', - data: inventory.toJson(), - ), - ); + if (config.sceneInventoryEmissionEnabled) { + final dedupeKey = [ + inventory.routeKey, + inventory.inventoryHash, + scrollContext?.dedupeKey ?? '', + ].join('|'); + if (_emittedInventories.add(dedupeKey)) { + _addEvent( + TugboatEvent( + id: _nextId('event'), + atMs: atMs, + type: 'scene_inventory', + data: inventory.toJson(), + ), + ); + } } - if (emitViewportSemanticMap) { + if (emitViewportSemanticMap && config.semanticMapEmissionEnabled) { _viewportSemantics.maybeEmit( inventory, resolver: _anchorResolver, @@ -5663,8 +5658,8 @@ class TugboatReplayController extends ChangeNotifier { /// Publishes one timeline event. /// - /// When [attachActionContext] is true (default), stamps the active - /// exploration action window. Host app/network evidence passes false so it + /// When [attachActionContext] is true (default), stamps the active action + /// context. Host app/network evidence passes false so it /// never inherits [actionId] or interaction context. void _addEvent(TugboatEvent event, {bool attachActionContext = true}) { final session = _session; @@ -5728,8 +5723,7 @@ class TugboatReplayController extends ChangeNotifier { required String name, String? source, Map? parameters, - TugboatParameterPolicy parameterPolicy = - TugboatParameterPolicy.allowAllInProduction, + TugboatParameterPolicy parameterPolicy = TugboatParameterPolicy.allowAll, }) { _evidence.recordExternalEvent( name: name, @@ -5747,23 +5741,21 @@ class TugboatReplayController extends ChangeNotifier { return _evidence.beginNetworkCall(method: method, route: route); } - void setExplorationActionWindow({ - required String explorationRunId, - required String actionId, - }) { - _activeExplorationRunId = explorationRunId; + void setActionContext({required String runId, required String actionId}) { + if (!config.actionContextEnabled) return; + _activeExplorationRunId = runId; _activeActionId = actionId; _addEvent( TugboatEvent( id: _nextId('event'), atMs: atMs, type: 'action_window_set', - data: {'actionId': actionId, 'explorationRunId': explorationRunId}, + data: {'actionId': actionId, 'explorationRunId': runId}, ), ); } - void clearExplorationActionWindow() { + void clearActionContext() { final clearedActionId = _activeActionId; if (clearedActionId != null) { _addEvent( @@ -5797,14 +5789,11 @@ class TugboatReplayController extends ChangeNotifier { final runId = message['explorationRunId'] as String?; final actionId = message['actionId'] as String?; if (runId != null && actionId != null) { - setExplorationActionWindow( - explorationRunId: runId, - actionId: actionId, - ); + setActionContext(runId: runId, actionId: actionId); _explorationSink?.transport.acknowledge(type!, actionId: actionId); } case 'clear_action_window': - clearExplorationActionWindow(); + clearActionContext(); _explorationSink?.transport.acknowledge( type!, actionId: message['actionId'] as String?, diff --git a/sdks/flutter/packages/tugboat/lib/src/evidence_recorder.dart b/sdks/flutter/packages/tugboat/lib/src/evidence_recorder.dart index 1a79661..01c6683 100644 --- a/sdks/flutter/packages/tugboat/lib/src/evidence_recorder.dart +++ b/sdks/flutter/packages/tugboat/lib/src/evidence_recorder.dart @@ -1,4 +1,3 @@ -import 'capture_profile.dart'; import 'external_event.dart'; import 'health.dart'; import 'models.dart'; @@ -15,13 +14,11 @@ class TugboatEvidenceRecorder { required this.appendEvidence, required this.nextEventId, required this.nowMs, - required this.profile, }); final void Function(TugboatEvent event) appendEvidence; final String Function(String prefix) nextEventId; final int Function() nowMs; - final TugboatCaptureProfile Function() profile; static const int _maxCount = 10000; @@ -66,8 +63,7 @@ class TugboatEvidenceRecorder { required String name, String? source, Map? parameters, - TugboatParameterPolicy parameterPolicy = - TugboatParameterPolicy.allowAllInProduction, + TugboatParameterPolicy parameterPolicy = TugboatParameterPolicy.allowAll, }) { try { if (!accepting) { @@ -91,9 +87,8 @@ class TugboatEvidenceRecorder { source, TugboatParameterLimits.maxSourceLength, ); - final effectivePolicy = parameterPolicy.effectiveFor(profile()); final snapshot = snapshotExternalParameters( - policy: effectivePolicy, + policy: parameterPolicy, parameters: parameters, ); if (!accepting) { diff --git a/sdks/flutter/packages/tugboat/lib/src/external_event.dart b/sdks/flutter/packages/tugboat/lib/src/external_event.dart index fb98683..8742a7a 100644 --- a/sdks/flutter/packages/tugboat/lib/src/external_event.dart +++ b/sdks/flutter/packages/tugboat/lib/src/external_event.dart @@ -1,23 +1,17 @@ import 'dart:convert'; -import 'capture_profile.dart'; - /// Closed vocabulary for how external-event parameter values were retained. enum TugboatParameterCaptureMode { namesOnly, allowList, transform, - allowAll, - allowAllInProduction; + allowAll; String get wireName => switch (this) { TugboatParameterCaptureMode.namesOnly => 'names_only', TugboatParameterCaptureMode.allowList => 'allow_list', TugboatParameterCaptureMode.transform => 'transform', - // Both retain-all modes share the wire label; production scope is carried - // by the mode itself, not a parallel flag. - TugboatParameterCaptureMode.allowAll || - TugboatParameterCaptureMode.allowAllInProduction => 'allow_all', + TugboatParameterCaptureMode.allowAll => 'allow_all', }; } @@ -29,11 +23,9 @@ class TugboatParameterDrop { /// Policy controlling which external-event parameter values are retained. /// -/// The default policy is [allowAllInProduction]: event name, parameter keys, -/// and JSON-safe values are retained within hard limits in every capture -/// profile. [namesOnly] keeps keys without values. Values can also be limited -/// through an allow-list, a transform, or the exploration-only [allowAll] -/// escape hatch. +/// The default policy is [allowAll]: event name, parameter keys, and JSON-safe +/// values are retained within hard limits. [namesOnly] keeps keys without +/// values. Values can also be limited through an allow-list or a transform. class TugboatParameterPolicy { const TugboatParameterPolicy._({ required this.mode, @@ -64,27 +56,13 @@ class TugboatParameterPolicy { valueTransform: transform, ); - /// Exploration-only escape hatch that retains all JSON-safe values within - /// hard limits. Can capture feedback text, search terms, IDs, and other user - /// content. Do not use as the default production example. - /// - /// Outside [TugboatCaptureProfile.exploration], [effectiveFor] downgrades - /// this to [namesOnly]. - static const allowAll = TugboatParameterPolicy._( - mode: TugboatParameterCaptureMode.allowAll, - ); - - /// Default policy. Retains all JSON-safe values within hard limits, - /// including in production capture profiles. + /// Retains all JSON-safe values within hard limits. /// - /// This can capture feedback, search terms, URLs, IDs, and other user + /// This can capture feedback text, search terms, IDs, and other user /// content. Hosts that must not retain values should use [namesOnly], an /// allow-list, or a transform. - /// - /// Unlike [allowAll], this policy retains values in production capture - /// profiles. It still applies all parameter safety and size limits. - static const allowAllInProduction = TugboatParameterPolicy._( - mode: TugboatParameterCaptureMode.allowAllInProduction, + static const allowAll = TugboatParameterPolicy._( + mode: TugboatParameterCaptureMode.allowAll, ); /// Sentinel for transform callbacks. @@ -96,15 +74,6 @@ class TugboatParameterPolicy { /// Wire label for capture metadata (`names_only`, `allow_list`, …). String get captureValues => mode.wireName; - - /// Resolves exploration-only escape hatches against the active profile. - TugboatParameterPolicy effectiveFor(TugboatCaptureProfile profile) { - if (mode == TugboatParameterCaptureMode.allowAll && - profile != TugboatCaptureProfile.exploration) { - return namesOnly; - } - return this; - } } /// Hard limits applied when snapshotting external-event parameters. @@ -303,8 +272,7 @@ _ValueDecision _decideTopLevelValue( key, value, ), - TugboatParameterCaptureMode.allowAll || - TugboatParameterCaptureMode.allowAllInProduction => _KeepValue(value), + TugboatParameterCaptureMode.allowAll => _KeepValue(value), }; } diff --git a/sdks/flutter/packages/tugboat/lib/src/health.dart b/sdks/flutter/packages/tugboat/lib/src/health.dart index c2e4e29..03eb191 100644 --- a/sdks/flutter/packages/tugboat/lib/src/health.dart +++ b/sdks/flutter/packages/tugboat/lib/src/health.dart @@ -2,7 +2,6 @@ class TugboatSdkHealth { const TugboatSdkHealth({ required this.lifecycle, - required this.profile, this.activationRequestId, this.captureSessionId, this.sinks = const TugboatSinkHealth(), @@ -14,7 +13,6 @@ class TugboatSdkHealth { }); final String lifecycle; - final String profile; final String? activationRequestId; final String? captureSessionId; final TugboatSinkHealth sinks; @@ -31,7 +29,6 @@ class TugboatSdkHealth { Map toJson() => { 'lifecycle': lifecycle, - 'profile': profile, if (activationRequestId != null) 'activationRequestId': activationRequestId, if (captureSessionId != null) 'captureSessionId': captureSessionId, 'sinks': sinks.toJson(), diff --git a/sdks/flutter/packages/tugboat/lib/src/lifecycle.dart b/sdks/flutter/packages/tugboat/lib/src/lifecycle.dart index 814709a..ef766e4 100644 --- a/sdks/flutter/packages/tugboat/lib/src/lifecycle.dart +++ b/sdks/flutter/packages/tugboat/lib/src/lifecycle.dart @@ -1,7 +1,5 @@ import 'package:flutter/foundation.dart'; -import 'capture_profile.dart'; - /// Explicit capture lifecycle states owned by the SDK gate. enum TugboatLifecycleState { dormant, starting, active, stopping } @@ -10,20 +8,12 @@ class TugboatLifecycleNotifier extends ChangeNotifier { TugboatLifecycleState _state = TugboatLifecycleState.dormant; int _requestEpoch = 0; String? _activationRequestId; - TugboatCaptureProfile? _activeProfile; + bool? _captureOverride; bool _disabled = false; TugboatLifecycleState get state => _state; int get requestEpoch => _requestEpoch; String? get activationRequestId => _activationRequestId; - TugboatCaptureProfile? get activeProfile { - final profile = _activeProfile; - if (profile == null || profile == TugboatCaptureProfile.dormant) { - return null; - } - return profile; - } - bool get disabled => _disabled; bool get isActivated => _activationRequestId != null && @@ -31,60 +21,38 @@ class TugboatLifecycleNotifier extends ChangeNotifier { _state == TugboatLifecycleState.active || _state == TugboatLifecycleState.stopping); - /// Whether the gate should mount capture machinery for [configProfile]. - bool shouldCapture(TugboatCaptureProfile configProfile) { + /// Whether the gate should mount capture machinery for [configEnabled]. + bool shouldCapture(bool configEnabled) { if (_disabled) return false; - if (_activeProfile != null) { - return _activeProfile != TugboatCaptureProfile.dormant; - } - return configProfile != TugboatCaptureProfile.dormant; - } - - TugboatCaptureProfile effectiveProfile(TugboatCaptureProfile configProfile) { - final override = _activeProfile; - if (override != null && override != TugboatCaptureProfile.dormant) { - return override; - } - if (override == TugboatCaptureProfile.dormant) { - return TugboatCaptureProfile.dormant; - } - return configProfile; + return _captureOverride ?? configEnabled; } - /// Enables capture. Identical request+profile while already capturing is - /// idempotent. A different request or profile while active bumps the epoch - /// and forces stop-then-start via a new capture key. - void activate({ - required String activationRequestId, - TugboatCaptureProfile profile = TugboatCaptureProfile.productionLean, - }) { + /// Enables capture. An identical active request is idempotent. + void activate({required String activationRequestId}) { if (_disabled) return; - if (profile == TugboatCaptureProfile.dormant) return; final sameRequest = _activationRequestId == activationRequestId && - _activeProfile == profile && + _captureOverride == true && (_state == TugboatLifecycleState.starting || _state == TugboatLifecycleState.active); if (sameRequest) return; _requestEpoch += 1; _activationRequestId = activationRequestId; - _activeProfile = profile; + _captureOverride = true; _state = TugboatLifecycleState.starting; notifyListeners(); } void deactivate() { if (_state == TugboatLifecycleState.dormant && - _activeProfile == null && _activationRequestId == null) { return; } _requestEpoch += 1; _activationRequestId = null; - // Explicit dormant override so config-active profiles also stop. - _activeProfile = TugboatCaptureProfile.dormant; + _captureOverride = false; _state = TugboatLifecycleState.stopping; notifyListeners(); } @@ -95,12 +63,10 @@ class TugboatLifecycleNotifier extends ChangeNotifier { if (value) { _requestEpoch += 1; _activationRequestId = null; - _activeProfile = TugboatCaptureProfile.dormant; _state = TugboatLifecycleState.stopping; - } else if (_activeProfile == TugboatCaptureProfile.dormant && - _activationRequestId == null) { - // Re-enable: drop forced dormant so config profiles can capture again. - _activeProfile = null; + } else if (_activationRequestId == null) { + // Re-enable: drop the runtime override so configuration applies again. + _captureOverride = null; _state = TugboatLifecycleState.dormant; } notifyListeners(); @@ -110,12 +76,30 @@ class TugboatLifecycleNotifier extends ChangeNotifier { void resetForTest() { _disabled = false; _activationRequestId = null; - _activeProfile = null; + _captureOverride = null; _state = TugboatLifecycleState.dormant; _requestEpoch += 1; notifyListeners(); } + /// Called by the gate before it mounts configuration-enabled capture. + void markStarting(int epoch) { + if (_disabled || epoch != _requestEpoch) return; + if (_state != TugboatLifecycleState.dormant) return; + _state = TugboatLifecycleState.starting; + notifyListeners(); + } + + /// Advances the session epoch while the gate rebuilds for changed grants. + /// + /// The gate is already rebuilding, so [markActive] sends the next lifecycle + /// notification after the replacement session starts. + int beginCapabilityRemount() { + _requestEpoch += 1; + _state = TugboatLifecycleState.starting; + return _requestEpoch; + } + /// Called by the gate when capture machinery is fully up. void markActive(int epoch) { if (epoch != _requestEpoch) return; @@ -135,8 +119,7 @@ class TugboatLifecycleNotifier extends ChangeNotifier { return; } if (epoch != _requestEpoch) return; - if (_activeProfile != null && - _activeProfile != TugboatCaptureProfile.dormant) { + if (_captureOverride == true) { // Replacement still requested under this epoch — stay starting/active. if (_state == TugboatLifecycleState.stopping) { _state = TugboatLifecycleState.starting; @@ -145,7 +128,6 @@ class TugboatLifecycleNotifier extends ChangeNotifier { return; } _state = TugboatLifecycleState.dormant; - _activeProfile = null; notifyListeners(); } } diff --git a/sdks/flutter/packages/tugboat/lib/src/replay_config.dart b/sdks/flutter/packages/tugboat/lib/src/replay_config.dart index a36257a..5176617 100644 --- a/sdks/flutter/packages/tugboat/lib/src/replay_config.dart +++ b/sdks/flutter/packages/tugboat/lib/src/replay_config.dart @@ -1,4 +1,3 @@ -import 'capture_profile.dart'; import 'collector_config.dart'; import 'interaction_transaction.dart' show tugboatDefaultReconciliationWindow; import 'screenshot_capture_backend.dart'; @@ -8,8 +7,7 @@ import 'viewport_semantic_mode.dart'; export 'viewport_semantic_mode.dart' show TugboatViewportSemanticMode; -/// Resolved viewport-semantics capabilities from [TugboatCaptureProfile] + -/// [TugboatViewportSemanticMode]. Single source of truth for the controller. +/// Resolved viewport-semantics capabilities for the controller. class TugboatViewportSemanticPolicy { const TugboatViewportSemanticPolicy({ required this.engineEnabled, @@ -32,34 +30,24 @@ class TugboatViewportSemanticPolicy { } TugboatViewportSemanticPolicy resolveViewportSemanticPolicy({ - required TugboatCaptureProfile profile, required TugboatViewportSemanticMode mode, + bool emitEvents = false, }) { if (mode == TugboatViewportSemanticMode.off) { return TugboatViewportSemanticPolicy.off; } - if (profile == TugboatCaptureProfile.dormant) { - return TugboatViewportSemanticPolicy.off; - } - - final exploration = profile == TugboatCaptureProfile.exploration; - final production = profile == TugboatCaptureProfile.productionLean; - if (!exploration && !production) { - return TugboatViewportSemanticPolicy.off; - } - final verboseMode = mode == TugboatViewportSemanticMode.full || mode == TugboatViewportSemanticMode.fullWithDebugLogs; - final emitEvents = exploration && verboseMode; + final shouldEmitEvents = emitEvents && verboseMode; final debugLogs = - exploration && mode == TugboatViewportSemanticMode.fullWithDebugLogs; + emitEvents && mode == TugboatViewportSemanticMode.fullWithDebugLogs; return TugboatViewportSemanticPolicy( engineEnabled: true, - emitEvents: emitEvents, + emitEvents: shouldEmitEvents, debugLogs: debugLogs, - holdPersistentSemanticsHandle: exploration, + holdPersistentSemanticsHandle: shouldEmitEvents, ); } @@ -82,7 +70,7 @@ class TugboatScreenshotBudgetConfig { /// Capture session configuration. class TugboatReplayConfig { const TugboatReplayConfig({ - this.profile = TugboatCaptureProfile.dormant, + this.enabled = false, this.settleDelay = const Duration(seconds: 1), this.scrollEndCaptureDelay = Duration.zero, this.interactionClaimWindow = tugboatDefaultReconciliationWindow, @@ -96,6 +84,10 @@ class TugboatReplayConfig { this.captureMaxHeight, this.degradedCaptureScale = 0.80, this.enableGlobalPointerCapture = true, + this.emitSceneInventory = false, + this.emitViewportSemanticMap = false, + this.emitCaptureDiagnostics = false, + this.acceptActionContext = false, this.explorationCollectorUrl, this.explorationRunId, this.userId, @@ -115,7 +107,10 @@ class TugboatReplayConfig { assert(captureMaxHeight == null || captureMaxHeight > 0), assert(degradedCaptureScale > 0 && degradedCaptureScale <= 1); - final TugboatCaptureProfile profile; + /// Whether the normal privacy-safe capture lifecycle starts with the app. + /// + /// [TugboatReplay.activate] can start a disabled configuration at runtime. + final bool enabled; final Duration settleDelay; /// Delay before the optional visual observation taken after a scroll ends. @@ -135,7 +130,7 @@ class TugboatReplayConfig { final bool captureScrollSamples; /// Whether to request visual checkpoints while a scroll is in progress. - /// Production profiles should normally keep this false and retain scroll + /// Most applications should keep this false and retain scroll /// metrics instead; a single deferred scroll-end frame is cheaper and more /// coherent than repeated full-screen readbacks during scrolling. final bool captureScrollScreenshots; @@ -146,6 +141,19 @@ class TugboatReplayConfig { /// Additional scale applied while the rolling screenshot budget is degraded. final double degradedCaptureScale; final bool enableGlobalPointerCapture; + + /// Allows bounded `scene_inventory` events for this app launch. + final bool emitSceneInventory; + + /// Allows bounded viewport semantic-map events for this app launch. + final bool emitViewportSemanticMap; + + /// Allows bounded capture diagnostic events for this app launch. + final bool emitCaptureDiagnostics; + + /// Allows the host to attach external action context to captured events. + final bool acceptActionContext; + final String? explorationCollectorUrl; final String? explorationRunId; final String? userId; @@ -164,22 +172,22 @@ class TugboatReplayConfig { final TugboatScreenshotCaptureBackend screenshotCaptureBackend; TugboatScreenshotMaskLevel get effectiveScreenshotMaskLevel => - screenshotMaskLevel ?? - switch (profile) { - TugboatCaptureProfile.productionLean => - TugboatScreenshotMaskLevel.allTextAndMedia, - TugboatCaptureProfile.dormant || TugboatCaptureProfile.exploration => - TugboatScreenshotMaskLevel.explicitOnly, - }; + screenshotMaskLevel ?? TugboatScreenshotMaskLevel.allTextAndMedia; + + bool get sceneInventoryEmissionEnabled => emitSceneInventory; + + bool get semanticMapEmissionEnabled => viewportSemanticPolicy.emitEvents; + + bool get actionContextEnabled => acceptActionContext; TugboatViewportSemanticPolicy get viewportSemanticPolicy => resolveViewportSemanticPolicy( - profile: profile, mode: viewportSemanticMode, + emitEvents: emitViewportSemanticMap, ); TugboatReplayConfig copyWith({ - TugboatCaptureProfile? profile, + bool? enabled, Duration? settleDelay, Duration? scrollEndCaptureDelay, Duration? interactionClaimWindow, @@ -195,6 +203,10 @@ class TugboatReplayConfig { bool clearCaptureMaxHeight = false, double? degradedCaptureScale, bool? enableGlobalPointerCapture, + bool? emitSceneInventory, + bool? emitViewportSemanticMap, + bool? emitCaptureDiagnostics, + bool? acceptActionContext, String? explorationCollectorUrl, String? explorationRunId, String? userId, @@ -210,7 +222,7 @@ class TugboatReplayConfig { TugboatScreenshotCaptureBackend? screenshotCaptureBackend, }) { return TugboatReplayConfig( - profile: _or(profile, this.profile), + enabled: _or(enabled, this.enabled), settleDelay: _or(settleDelay, this.settleDelay), scrollEndCaptureDelay: _or( scrollEndCaptureDelay, @@ -249,6 +261,16 @@ class TugboatReplayConfig { enableGlobalPointerCapture, this.enableGlobalPointerCapture, ), + emitSceneInventory: _or(emitSceneInventory, this.emitSceneInventory), + emitViewportSemanticMap: _or( + emitViewportSemanticMap, + this.emitViewportSemanticMap, + ), + emitCaptureDiagnostics: _or( + emitCaptureDiagnostics, + this.emitCaptureDiagnostics, + ), + acceptActionContext: _or(acceptActionContext, this.acceptActionContext), explorationCollectorUrl: _or( explorationCollectorUrl, this.explorationCollectorUrl, diff --git a/sdks/flutter/packages/tugboat/lib/src/sdk_version.dart b/sdks/flutter/packages/tugboat/lib/src/sdk_version.dart index c515f17..5f66240 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.8.17'; +const tugboatSdkVersion = '0.9.0'; diff --git a/sdks/flutter/packages/tugboat/lib/src/sinks/capture_sink.dart b/sdks/flutter/packages/tugboat/lib/src/sinks/capture_sink.dart index f09ffe1..b9e57a6 100644 --- a/sdks/flutter/packages/tugboat/lib/src/sinks/capture_sink.dart +++ b/sdks/flutter/packages/tugboat/lib/src/sinks/capture_sink.dart @@ -10,14 +10,12 @@ class TugboatSinkSessionContext { required this.sessionEpoch, this.activationRequestId, this.explorationRunId, - this.profileName, }); final String captureSessionId; final int sessionEpoch; final String? activationRequestId; final String? explorationRunId; - final String? profileName; } /// Immutable delivery unit for sink mailboxes. diff --git a/sdks/flutter/packages/tugboat/lib/src/tugboat.dart b/sdks/flutter/packages/tugboat/lib/src/tugboat.dart index 150e4af..de1940d 100644 --- a/sdks/flutter/packages/tugboat/lib/src/tugboat.dart +++ b/sdks/flutter/packages/tugboat/lib/src/tugboat.dart @@ -5,7 +5,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'capture_boundary.dart'; -import 'capture_profile.dart'; import 'controller.dart'; import 'external_event.dart'; import 'health.dart'; @@ -14,7 +13,6 @@ import 'lifecycle.dart'; import 'models.dart'; import 'network_observer.dart'; -export 'capture_profile.dart' show TugboatCaptureProfile; export 'lifecycle.dart' show TugboatLifecycleState; export 'screenshot_mask_level.dart' show TugboatScreenshotMaskLevel; export 'markers.dart' @@ -26,8 +24,8 @@ typedef TugboatControllerTestHook = /// Host-app entry point for Tugboat session capture. /// /// Install [navigatorObserver] on [MaterialApp]/[CupertinoApp] and wrap the -/// app builder with [wrapApp]. Capture stays dormant until [activate] when -/// using [TugboatCaptureProfile.dormant]. +/// app builder with [wrapApp]. Capture stays dormant until configuration or +/// [activate] enables it. /// /// [wrapApp] always mounts a lightweight activation gate so [activate] and /// [deactivate] take effect without requiring an unrelated host rebuild. @@ -72,8 +70,6 @@ class TugboatReplay { static bool get isActivated => _lifecycle.isActivated; static String? get activationRequestId => _lifecycle.activationRequestId; - static TugboatCaptureProfile? get activeProfile => _lifecycle.activeProfile; - /// When `true`, the SDK is fully inert (no capture, no wrapping overhead). /// /// Setting this to `true` tears down any active session. Intended for remote @@ -177,16 +173,9 @@ class TugboatReplay { _lifecycle.state != TugboatLifecycleState.stopping && (_controller?.acceptingEvidence ?? false); - /// Enables capture machinery for dormant builds at runtime. - /// - static void activate({ - required String activationRequestId, - TugboatCaptureProfile profile = TugboatCaptureProfile.productionLean, - }) { - _lifecycle.activate( - activationRequestId: activationRequestId, - profile: profile, - ); + /// Enables the normal capture lifecycle at runtime. + static void activate({required String activationRequestId}) { + _lifecycle.activate(activationRequestId: activationRequestId); } /// Returns the SDK to dormant mode without tearing down the host app. @@ -203,7 +192,6 @@ class TugboatReplay { if (c != null) return c.healthSnapshot(); return TugboatSdkHealth( lifecycle: _lifecycle.state.name, - profile: (_lifecycle.activeProfile ?? TugboatCaptureProfile.dormant).name, activationRequestId: _lifecycle.activationRequestId, ); } @@ -229,13 +217,12 @@ class TugboatReplay { /// dormant, disabled, or ended are safe no-ops. /// /// By default, [parameterPolicy] is - /// [TugboatParameterPolicy.allowAllInProduction], so JSON-safe parameter + /// [TugboatParameterPolicy.allowAll], so JSON-safe parameter /// values are retained within hard limits. Pass /// [TugboatParameterPolicy.namesOnly] to keep keys without values. static TugboatEventHook eventHook({ String? source, - TugboatParameterPolicy parameterPolicy = - TugboatParameterPolicy.allowAllInProduction, + TugboatParameterPolicy parameterPolicy = TugboatParameterPolicy.allowAll, }) { return _TugboatEventHook(source: source, parameterPolicy: parameterPolicy); } @@ -378,11 +365,24 @@ class _TugboatActivationGateState extends State<_TugboatActivationGate> { @override void didUpdateWidget(covariant _TugboatActivationGate oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.config.profile != widget.config.profile) { + if (oldWidget.config.enabled != widget.config.enabled) { _syncCaptureFlag(); } + if (_captureMounted && + _capabilitiesChanged(oldWidget.config, widget.config)) { + _mountedEpoch = TugboatReplay._lifecycle.beginCapabilityRemount(); + } } + bool _capabilitiesChanged( + TugboatReplayConfig previous, + TugboatReplayConfig current, + ) => + previous.emitSceneInventory != current.emitSceneInventory || + previous.emitViewportSemanticMap != current.emitViewportSemanticMap || + previous.emitCaptureDiagnostics != current.emitCaptureDiagnostics || + previous.acceptActionContext != current.acceptActionContext; + @override void dispose() { TugboatReplay._lifecycle.removeListener(_listener); @@ -396,7 +396,7 @@ class _TugboatActivationGateState extends State<_TugboatActivationGate> { void _syncCaptureFlag() { final lifecycle = TugboatReplay._lifecycle; - final should = lifecycle.shouldCapture(widget.config.profile); + final should = lifecycle.shouldCapture(widget.config.enabled); final epoch = lifecycle.requestEpoch; if (!should) { @@ -424,6 +424,7 @@ class _TugboatActivationGateState extends State<_TugboatActivationGate> { _captureMounted = true; _mountedEpoch = epoch; }); + lifecycle.markStarting(epoch); } } @@ -433,10 +434,9 @@ class _TugboatActivationGateState extends State<_TugboatActivationGate> { return widget.child; } final lifecycle = TugboatReplay._lifecycle; - final profile = lifecycle.effectiveProfile(widget.config.profile); return _TugboatReplayRoot( - key: ValueKey('tugboat-capture-$profile-${_mountedEpoch ?? 0}'), - config: widget.config.copyWith(profile: profile), + key: ValueKey('tugboat-capture-${_mountedEpoch ?? 0}'), + config: widget.config.copyWith(enabled: true), activationRequestId: lifecycle.activationRequestId, sessionEpoch: _mountedEpoch ?? lifecycle.requestEpoch, child: widget.child, diff --git a/sdks/flutter/packages/tugboat/lib/tugboat.dart b/sdks/flutter/packages/tugboat/lib/tugboat.dart index 4c0d04f..bf30869 100644 --- a/sdks/flutter/packages/tugboat/lib/tugboat.dart +++ b/sdks/flutter/packages/tugboat/lib/tugboat.dart @@ -7,7 +7,6 @@ export 'src/anchors.dart' tugboatIconLabel, tugboatIconHash, tugboatLabelHash; -export 'src/capture_profile.dart' show TugboatCaptureProfile; export 'src/capture_sink.dart' show TugboatCaptureSink, diff --git a/sdks/flutter/packages/tugboat/pubspec.yaml b/sdks/flutter/packages/tugboat/pubspec.yaml index b4916ad..e84be13 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.8.17 +version: 0.9.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/exploration_pre_tap_test.dart b/sdks/flutter/packages/tugboat/test/exploration_pre_tap_test.dart index 102ad63..9ba3f7a 100644 --- a/sdks/flutter/packages/tugboat/test/exploration_pre_tap_test.dart +++ b/sdks/flutter/packages/tugboat/test/exploration_pre_tap_test.dart @@ -6,8 +6,12 @@ import 'package:tugboat/src/anchors.dart'; import 'package:tugboat/src/interaction_transaction.dart'; import 'package:tugboat/tugboat.dart'; -const _explorationConfig = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, +const _inventoryConfig = TugboatReplayConfig( + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -62,7 +66,7 @@ class _DelayedCtaState extends State<_DelayedCta> { Future _mountController( WidgetTester tester, Widget child, { - TugboatReplayConfig config = _explorationConfig, + TugboatReplayConfig config = _inventoryConfig, }) async { TugboatReplay.debugConfigureControllerForTest = (controller) { controller.debugExecuteCapture = @@ -568,11 +572,11 @@ void main() { }, ); - testWidgets('production pointer-down does not use exploration capture', ( + testWidgets('default pointer-down does not use inventory capture', ( tester, ) async { const config = TugboatReplayConfig( - profile: TugboatCaptureProfile.productionLean, + enabled: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -604,14 +608,45 @@ void main() { expect(eventTypes, isNot(contains('viewport_semantic_map'))); }); - testWidgets('dormant profile performs no pointer-down capture work', ( + testWidgets('inventory pre-tap work does not emit diagnostics by default', ( + tester, + ) async { + final controller = await _mountController( + tester, + Scaffold( + body: FilledButton(onPressed: () {}, child: const Text('Go')), + ), + config: const TugboatReplayConfig( + enabled: true, + emitSceneInventory: true, + settleDelay: Duration.zero, + interactionClaimWindow: Duration.zero, + enableGlobalPointerCapture: false, + capturePixelRatio: 1, + ), + ); + final point = tester.getCenter(find.text('Go')); + final buildsBefore = controller.debugAnchorTokenMapBuildCount; + + controller.recordPointerDown(point); + + expect(controller.debugAnchorTokenMapBuildCount, greaterThan(buildsBefore)); + expect( + controller.session!.events.where( + (event) => event.type == 'exploration_pre_tap_diagnostic', + ), + isEmpty, + ); + }); + + testWidgets('disabled capture performs no pointer-down capture work', ( tester, ) async { await tester.pumpWidget( MaterialApp( builder: (context, child) => TugboatReplay.wrapApp( config: const TugboatReplayConfig( - profile: TugboatCaptureProfile.dormant, + enabled: false, enableGlobalPointerCapture: false, ), child: child!, diff --git a/sdks/flutter/packages/tugboat/test/external_event_and_network_test.dart b/sdks/flutter/packages/tugboat/test/external_event_and_network_test.dart index dedc1e1..54eb88a 100644 --- a/sdks/flutter/packages/tugboat/test/external_event_and_network_test.dart +++ b/sdks/flutter/packages/tugboat/test/external_event_and_network_test.dart @@ -4,7 +4,11 @@ import 'package:tugboat/src/external_event.dart'; import 'package:tugboat/tugboat.dart'; const _testConfig = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -134,15 +138,10 @@ void main() { }); }); - testWidgets('default policy retains parameter values in production capture', ( + testWidgets('default policy retains parameter values during capture', ( tester, ) async { - await _pumpCapture( - tester, - config: _testConfig.copyWith( - profile: TugboatCaptureProfile.productionLean, - ), - ); + await _pumpCapture(tester, config: _testConfig.copyWith(enabled: true)); TugboatReplay.eventHook().record( 'SEARCH', @@ -161,44 +160,13 @@ void main() { }); }); - testWidgets('production capture downgrades allow-all to names-only', ( + testWidgets('explicit allow-all retains bounded JSON-safe parameter values', ( tester, ) async { - await _pumpCapture( - tester, - config: _testConfig.copyWith( - profile: TugboatCaptureProfile.productionLean, - ), - ); + await _pumpCapture(tester, config: _testConfig.copyWith(enabled: true)); TugboatReplay.eventHook( parameterPolicy: TugboatParameterPolicy.allowAll, - ).record('SEARCH', parameters: {'query': 'private search'}); - - final event = TugboatReplay.controller!.session!.events.singleWhere( - (event) => event.type == 'external_event', - ); - expect(event.data['parameterKeys'], ['query']); - expect(event.data.containsKey('parameters'), isFalse); - expect(event.data['capture'], { - 'values': 'names_only', - 'truncated': false, - 'droppedCount': 0, - }); - }); - - testWidgets('production opt-in retains bounded JSON-safe parameter values', ( - tester, - ) async { - await _pumpCapture( - tester, - config: _testConfig.copyWith( - profile: TugboatCaptureProfile.productionLean, - ), - ); - - TugboatReplay.eventHook( - parameterPolicy: TugboatParameterPolicy.allowAllInProduction, ).record('SEARCH', parameters: {'query': 'private search', 'page': 2}); final event = TugboatReplay.controller!.session!.events.singleWhere( @@ -213,7 +181,7 @@ void main() { }); }); - testWidgets('exploration capture retains allow-all parameter values', ( + testWidgets('capability capture retains allow-all parameter values', ( tester, ) async { await _pumpCapture(tester); @@ -234,42 +202,18 @@ void main() { }); }); - test('effectiveFor only downgrades exploration allowAll', () { + test('allowAll has one mode-free policy', () { expect( - TugboatParameterPolicy.allowAll.effectiveFor( - TugboatCaptureProfile.productionLean, - ), - same(TugboatParameterPolicy.namesOnly), - ); - expect( - TugboatParameterPolicy.allowAllInProduction.effectiveFor( - TugboatCaptureProfile.productionLean, - ), - same(TugboatParameterPolicy.allowAllInProduction), - ); - expect( - TugboatParameterPolicy.allowAllInProduction.mode, - TugboatParameterCaptureMode.allowAllInProduction, - ); - expect( - TugboatParameterPolicy.allowAllInProduction.captureValues, - 'allow_all', - ); - expect( - TugboatParameterPolicy.allowAll.effectiveFor( - TugboatCaptureProfile.exploration, - ), - same(TugboatParameterPolicy.allowAll), + TugboatParameterPolicy.allowAll.mode, + TugboatParameterCaptureMode.allowAll, ); + expect(TugboatParameterPolicy.allowAll.captureValues, 'allow_all'); }); testWidgets('external event ignores active action window', (tester) async { await _pumpCapture(tester); final controller = TugboatReplay.controller!; - controller.setExplorationActionWindow( - explorationRunId: 'run-1', - actionId: 'A-1', - ); + controller.setActionContext(runId: 'run-1', actionId: 'A-1'); TugboatReplay.eventHook(source: 'analytics').record('PING'); final call = TugboatReplay.beginNetworkCall( diff --git a/sdks/flutter/packages/tugboat/test/helpers/replay_coherence_harness.dart b/sdks/flutter/packages/tugboat/test/helpers/replay_coherence_harness.dart index aabd0ed..67eb44c 100644 --- a/sdks/flutter/packages/tugboat/test/helpers/replay_coherence_harness.dart +++ b/sdks/flutter/packages/tugboat/test/helpers/replay_coherence_harness.dart @@ -218,7 +218,9 @@ class ReplayCoherenceHarness { // recordings now default to canonical-only publication. this.maxFrames = 300, this.screenshotBudget = TugboatScreenshotBudgetConfig.defaults, - this.profile = TugboatCaptureProfile.exploration, + this.enabled = true, + this.emitViewportSemanticMap = true, + this.emitCaptureDiagnostics = true, GlobalKey? boundaryKey, }) : boundaryKey = boundaryKey ?? GlobalKey(); @@ -226,7 +228,9 @@ class ReplayCoherenceHarness { final Duration interactionClaimWindow; final int maxFrames; final TugboatScreenshotBudgetConfig screenshotBudget; - final TugboatCaptureProfile profile; + final bool enabled; + final bool emitViewportSemanticMap; + final bool emitCaptureDiagnostics; final GlobalKey boundaryKey; final ControllableScheduler scheduler = ControllableScheduler(); final Map _frameProvenance = @@ -267,7 +271,11 @@ class ReplayCoherenceHarness { TestWidgetsFlutterBinding.ensureInitialized(); controller = TugboatReplayController( config: TugboatReplayConfig( - profile: profile, + enabled: enabled, + emitSceneInventory: true, + emitViewportSemanticMap: emitViewportSemanticMap, + emitCaptureDiagnostics: emitCaptureDiagnostics, + acceptActionContext: true, settleDelay: settleDelay, interactionClaimWindow: interactionClaimWindow, maxFrames: maxFrames, diff --git a/sdks/flutter/packages/tugboat/test/integration/release_compatibility_matrix_test.dart b/sdks/flutter/packages/tugboat/test/integration/release_compatibility_matrix_test.dart index e4c974d..f72f965 100644 --- a/sdks/flutter/packages/tugboat/test/integration/release_compatibility_matrix_test.dart +++ b/sdks/flutter/packages/tugboat/test/integration/release_compatibility_matrix_test.dart @@ -16,7 +16,11 @@ void main() { navigatorObservers: [TugboatReplay.navigatorObserver], builder: (context, child) => TugboatReplay.wrapApp( config: const TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -68,7 +72,11 @@ void main() { navigatorObservers: [TugboatReplay.navigatorObserver], builder: (context, child) => TugboatReplay.wrapApp( config: const TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -123,7 +131,7 @@ void main() { MaterialApp( builder: (context, child) => TugboatReplay.wrapApp( config: const TugboatReplayConfig( - profile: TugboatCaptureProfile.dormant, + enabled: false, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -136,10 +144,7 @@ void main() { await tester.pump(); expect(TugboatReplay.controller, isNull); - TugboatReplay.activate( - activationRequestId: 'matrix-req', - profile: TugboatCaptureProfile.exploration, - ); + TugboatReplay.activate(activationRequestId: 'matrix-req'); await waitForTugboatCaptureWork(tester); expect(TugboatReplay.controller, isNotNull); expect(TugboatReplay.health.activationRequestId, 'matrix-req'); @@ -171,7 +176,11 @@ void main() { navigatorObservers: [TugboatReplay.navigatorObserver], builder: (context, child) => TugboatReplay.wrapApp( config: const TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: true, diff --git a/sdks/flutter/packages/tugboat/test/locale_evidence_test.dart b/sdks/flutter/packages/tugboat/test/locale_evidence_test.dart index 7ca7da3..a77344e 100644 --- a/sdks/flutter/packages/tugboat/test/locale_evidence_test.dart +++ b/sdks/flutter/packages/tugboat/test/locale_evidence_test.dart @@ -18,7 +18,11 @@ void main() { final appLocale = ValueNotifier(const Locale('en', 'US')); addTearDown(appLocale.dispose); const config = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -77,7 +81,7 @@ void main() { testWidgets('setLocale covers apps outside Localizations', (tester) async { const config = TugboatReplayConfig( - profile: TugboatCaptureProfile.productionLean, + enabled: true, settleDelay: Duration.zero, enableGlobalPointerCapture: false, ); @@ -106,7 +110,11 @@ void main() { final appLocale = ValueNotifier(const Locale('en', 'US')); addTearDown(appLocale.dispose); const config = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, diff --git a/sdks/flutter/packages/tugboat/test/replay/capture_diagnostics_test.dart b/sdks/flutter/packages/tugboat/test/replay/capture_diagnostics_test.dart index c3d3b7b..e7eb9c5 100644 --- a/sdks/flutter/packages/tugboat/test/replay/capture_diagnostics_test.dart +++ b/sdks/flutter/packages/tugboat/test/replay/capture_diagnostics_test.dart @@ -11,29 +11,25 @@ List _diagnostics(TugboatSession session) => session.events Map _data(TugboatEvent event) => event.data; void main() { - test( - 'productionLean updates health without session capture_diagnostic events', - () async { - final harness = ReplayCoherenceHarness( - profile: TugboatCaptureProfile.productionLean, - ); - await harness.setUp(); - addTearDown(harness.dispose); - harness.seedRouteState(route: '/home', signature: 'home'); - final healthStart = harness.controller - .healthSnapshot() - .captureDiagnostics; + test('default capture updates health without diagnostic events', () async { + final harness = ReplayCoherenceHarness( + enabled: true, + emitCaptureDiagnostics: false, + ); + await harness.setUp(); + addTearDown(harness.dispose); + harness.seedRouteState(route: '/home', signature: 'home'); + final healthStart = harness.controller.healthSnapshot().captureDiagnostics; - final request = harness.controller.debugRequestCapture(force: true); - await harness.pumpMicrotasks(); - await request.resolution; + final request = harness.controller.debugRequestCapture(force: true); + await harness.pumpMicrotasks(); + await request.resolution; - expect(_diagnostics(harness.controller.session!), isEmpty); - final health = harness.controller.healthSnapshot().captureDiagnostics; - expect(health.total, healthStart.total + 1); - expect(health.lastOutcome, isNotNull); - }, - ); + expect(_diagnostics(harness.controller.session!), isEmpty); + final health = harness.controller.healthSnapshot().captureDiagnostics; + expect(health.total, healthStart.total + 1); + expect(health.lastOutcome, isNotNull); + }); test( 'compatible logical captures coalesce but retain per-request evidence', diff --git a/sdks/flutter/packages/tugboat/test/replay/modal_capture_visual_test.dart b/sdks/flutter/packages/tugboat/test/replay/modal_capture_visual_test.dart index f3ffcc0..5841a67 100644 --- a/sdks/flutter/packages/tugboat/test/replay/modal_capture_visual_test.dart +++ b/sdks/flutter/packages/tugboat/test/replay/modal_capture_visual_test.dart @@ -369,7 +369,11 @@ const _openNestedHost = Key('modal-open-nested-host'); const _openNestedSheet = Key('modal-open-nested-sheet'); const _config = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: true, diff --git a/sdks/flutter/packages/tugboat/test/replay/pan_zoom_gesture_test.dart b/sdks/flutter/packages/tugboat/test/replay/pan_zoom_gesture_test.dart index 1757340..f0193ba 100644 --- a/sdks/flutter/packages/tugboat/test/replay/pan_zoom_gesture_test.dart +++ b/sdks/flutter/packages/tugboat/test/replay/pan_zoom_gesture_test.dart @@ -189,7 +189,8 @@ void main() { tester, ) async { final harness = ReplayCoherenceHarness( - profile: TugboatCaptureProfile.productionLean, + enabled: true, + emitViewportSemanticMap: false, ); await harness.setUpWidgetBacked(tester); addTearDown(harness.dispose); @@ -269,7 +270,8 @@ void main() { 'pan travel continues after primary lift, replace=$replaceContact', (tester) async { final harness = ReplayCoherenceHarness( - profile: TugboatCaptureProfile.productionLean, + enabled: true, + emitViewportSemanticMap: false, ); await harness.setUpWidgetBacked(tester); addTearDown(harness.dispose); @@ -616,7 +618,8 @@ void main() { tester, ) async { final harness = ReplayCoherenceHarness( - profile: TugboatCaptureProfile.productionLean, + enabled: true, + emitViewportSemanticMap: false, ); await tester.pumpWidget( MaterialApp( @@ -774,7 +777,7 @@ void main() { MaterialApp( builder: (context, child) => TugboatReplay.wrapApp( config: TugboatReplayConfig( - profile: TugboatCaptureProfile.productionLean, + enabled: true, enableGlobalPointerCapture: globalCapture, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, @@ -839,7 +842,7 @@ void main() { MaterialApp( builder: (context, child) => TugboatReplay.wrapApp( config: TugboatReplayConfig( - profile: TugboatCaptureProfile.productionLean, + enabled: true, enableGlobalPointerCapture: globalCapture, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, diff --git a/sdks/flutter/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart b/sdks/flutter/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart index 74992a9..44ffeec 100644 --- a/sdks/flutter/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart +++ b/sdks/flutter/packages/tugboat/test/replay/replay_overlay_nested_navigation_matrix_test.dart @@ -101,7 +101,11 @@ class _OverlayFixture { routes: {'/root': (_) => const _RootPage()}, builder: (context, child) => TugboatReplay.wrapApp( config: const TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, enableGlobalPointerCapture: true, capturePixelRatio: 1, diff --git a/sdks/flutter/packages/tugboat/test/replay/route_overlay_identity_test.dart b/sdks/flutter/packages/tugboat/test/replay/route_overlay_identity_test.dart index 171e90b..d0edad1 100644 --- a/sdks/flutter/packages/tugboat/test/replay/route_overlay_identity_test.dart +++ b/sdks/flutter/packages/tugboat/test/replay/route_overlay_identity_test.dart @@ -134,7 +134,11 @@ class _OverlayIdentityFixture { routes: {'/home': (_) => const _HomePage()}, builder: (context, child) => TugboatReplay.wrapApp( config: const TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, enableGlobalPointerCapture: true, capturePixelRatio: 1, diff --git a/sdks/flutter/packages/tugboat/test/replay/tap_coordinate_transform_test.dart b/sdks/flutter/packages/tugboat/test/replay/tap_coordinate_transform_test.dart index 0f6471f..b81ffaa 100644 --- a/sdks/flutter/packages/tugboat/test/replay/tap_coordinate_transform_test.dart +++ b/sdks/flutter/packages/tugboat/test/replay/tap_coordinate_transform_test.dart @@ -22,7 +22,11 @@ void main() { MaterialApp( builder: (context, child) => TugboatReplay.wrapApp( config: TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: true, diff --git a/sdks/flutter/packages/tugboat/test/replay/tugboat_health_test.dart b/sdks/flutter/packages/tugboat/test/replay/tugboat_health_test.dart index 840f2ee..38200f8 100644 --- a/sdks/flutter/packages/tugboat/test/replay/tugboat_health_test.dart +++ b/sdks/flutter/packages/tugboat/test/replay/tugboat_health_test.dart @@ -31,7 +31,6 @@ void main() { test('health snapshot stays sanitized', () { final health = TugboatSdkHealth( lifecycle: 'active', - profile: 'productionLean', activationRequestId: 'req-1', captureSessionId: 'cap-1', sinks: const TugboatSinkHealth(pending: 2, accepted: 10, dropped: 1), diff --git a/sdks/flutter/packages/tugboat/test/scene_inventory_test.dart b/sdks/flutter/packages/tugboat/test/scene_inventory_test.dart index e83b726..eea58ef 100644 --- a/sdks/flutter/packages/tugboat/test/scene_inventory_test.dart +++ b/sdks/flutter/packages/tugboat/test/scene_inventory_test.dart @@ -189,7 +189,11 @@ void main() { 'tap-time inventory matches tap state signature and fingerprint', (tester) async { const config = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -252,7 +256,11 @@ void main() { tester, ) async { const config = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -310,7 +318,11 @@ void main() { tester, ) async { const config = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -350,11 +362,11 @@ void main() { expect(afterSecondTap, afterFirstTap); }); - testWidgets('production lean does not emit scene inventory events', ( + testWidgets('default capture does not emit scene inventory events', ( tester, ) async { const config = TugboatReplayConfig( - profile: TugboatCaptureProfile.productionLean, + enabled: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -387,7 +399,11 @@ void main() { testWidgets('scene inventory event is deduped per state', (tester) async { const config = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, diff --git a/sdks/flutter/packages/tugboat/test/scroll_attribution_test.dart b/sdks/flutter/packages/tugboat/test/scroll_attribution_test.dart index 4a02259..a1f6d5b 100644 --- a/sdks/flutter/packages/tugboat/test/scroll_attribution_test.dart +++ b/sdks/flutter/packages/tugboat/test/scroll_attribution_test.dart @@ -3,7 +3,11 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:tugboat/tugboat.dart'; const _scrollTestConfig = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: true, @@ -124,7 +128,11 @@ void main() { 'a replacement scroll keeps the completed scroll facts while dropping its delayed frame', (tester) async { const config = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, scrollEndCaptureDelay: Duration(seconds: 1), interactionClaimWindow: Duration.zero, @@ -267,7 +275,11 @@ void main() { tester, ) async { const config = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, scrollEndCaptureDelay: Duration(milliseconds: 30), interactionClaimWindow: Duration.zero, @@ -536,7 +548,11 @@ void main() { 'in-motion screenshots are independent from scroll sample retention', (tester) async { const config = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: true, diff --git a/sdks/flutter/packages/tugboat/test/scroll_playground_live_test.dart b/sdks/flutter/packages/tugboat/test/scroll_playground_live_test.dart index 4445d8d..6dc9965 100644 --- a/sdks/flutter/packages/tugboat/test/scroll_playground_live_test.dart +++ b/sdks/flutter/packages/tugboat/test/scroll_playground_live_test.dart @@ -7,7 +7,11 @@ import 'package:tugboat/tugboat.dart'; /// End-to-end verification of scroll playground interactions with event dump. void main() { const config = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: true, diff --git a/sdks/flutter/packages/tugboat/test/tugboat_replay_test.dart b/sdks/flutter/packages/tugboat/test/tugboat_replay_test.dart index 6abdbfd..e4c6fe3 100644 --- a/sdks/flutter/packages/tugboat/test/tugboat_replay_test.dart +++ b/sdks/flutter/packages/tugboat/test/tugboat_replay_test.dart @@ -17,7 +17,12 @@ import 'helpers/json_roundtrip.dart'; import 'helpers/widget_capture_wait.dart'; const _testConfig = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, + screenshotMaskLevel: TugboatScreenshotMaskLevel.explicitOnly, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -572,7 +577,11 @@ void main() { ); final controller = TugboatReplayController( config: const TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration(milliseconds: 50), interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -628,7 +637,11 @@ void main() { ); final controller = TugboatReplayController( config: const TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration(milliseconds: 50), interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -741,36 +754,42 @@ void main() { expect(session.frames.any((frame) => frame.masked), isTrue); }); - test('mask defaults follow the capture profile', () { + test('mask defaults stay privacy-safe with additive capabilities', () { expect( const TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, ).effectiveScreenshotMaskLevel, - TugboatScreenshotMaskLevel.explicitOnly, + TugboatScreenshotMaskLevel.allTextAndMedia, ); expect( - const TugboatReplayConfig( - profile: TugboatCaptureProfile.productionLean, - ).effectiveScreenshotMaskLevel, + const TugboatReplayConfig(enabled: true).effectiveScreenshotMaskLevel, TugboatScreenshotMaskLevel.allTextAndMedia, ); expect( const TugboatReplayConfig( - profile: TugboatCaptureProfile.productionLean, + enabled: true, screenshotMaskLevel: TugboatScreenshotMaskLevel.sensitiveInputsOnly, ).effectiveScreenshotMaskLevel, TugboatScreenshotMaskLevel.sensitiveInputsOnly, ); }); - testWidgets('productionLean automatically masks visible text', ( + testWidgets('default capture automatically masks visible text', ( tester, ) async { await tester.pumpWidget( MaterialApp( builder: (context, child) => TugboatReplay.wrapApp( - config: _testConfig.copyWith( - profile: TugboatCaptureProfile.productionLean, + config: const TugboatReplayConfig( + enabled: true, + settleDelay: Duration.zero, + interactionClaimWindow: Duration.zero, + enableGlobalPointerCapture: false, + capturePixelRatio: 1.0, ), child: child!, ), @@ -948,9 +967,7 @@ void main() { expect(pixel.b, closeTo(0x1a, 2)); }); - testWidgets('exploration action window annotates captured events', ( - tester, - ) async { + testWidgets('action context annotates captured events', (tester) async { await tester.pumpWidget( MaterialApp( builder: (context, child) => @@ -963,13 +980,10 @@ void main() { await _waitForCaptures(tester); final controller = TugboatReplay.controller!; - controller.setExplorationActionWindow( - explorationRunId: 'run-1', - actionId: 'A-1', - ); + controller.setActionContext(runId: 'run-1', actionId: 'A-1'); await tester.tap(find.text('Act')); await _waitForCaptures(tester); - controller.clearExplorationActionWindow(); + controller.clearActionContext(); final actionEvents = controller.session!.events .where((event) => event.actionId == 'A-1') @@ -981,6 +995,41 @@ void main() { ); }); + testWidgets('action context is ignored without its capability', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => TugboatReplay.wrapApp( + config: const TugboatReplayConfig( + enabled: true, + settleDelay: Duration.zero, + interactionClaimWindow: Duration.zero, + enableGlobalPointerCapture: false, + capturePixelRatio: 1.0, + ), + child: child!, + ), + home: Scaffold( + body: FilledButton(onPressed: () {}, child: const Text('Act')), + ), + ), + ); + await _waitForCaptures(tester); + + final controller = TugboatReplay.controller!; + controller.setActionContext(runId: 'run-1', actionId: 'A-1'); + await tester.tap(find.text('Act')); + await _waitForCaptures(tester); + + expect( + controller.session!.events.any( + (event) => event.type == 'action_window_set' || event.actionId != null, + ), + isFalse, + ); + }); + testWidgets('canonical interaction keeps its pointer-down action window', ( tester, ) async { @@ -996,15 +1045,9 @@ void main() { await _waitForCaptures(tester); final controller = TugboatReplay.controller!; - controller.setExplorationActionWindow( - explorationRunId: 'run-1', - actionId: 'A-origin', - ); + controller.setActionContext(runId: 'run-1', actionId: 'A-origin'); await tester.tap(find.text('Act')); - controller.setExplorationActionWindow( - explorationRunId: 'run-1', - actionId: 'A-next', - ); + controller.setActionContext(runId: 'run-1', actionId: 'A-next'); await _waitForCaptures(tester); final interaction = controller.session!.events.singleWhere( @@ -1352,14 +1395,114 @@ void main() { expect(TugboatReplay.controller, isNull); expect(find.text('Disabled'), findsOneWidget); - TugboatReplay.activate( - activationRequestId: 'session-disabled', - profile: TugboatCaptureProfile.exploration, - ); + TugboatReplay.activate(activationRequestId: 'session-disabled'); expect(TugboatReplay.isActivated, isFalse); }); - testWidgets('dormant profile stays inert until activated without rebuild', ( + test('deactivate is a no-op from the initial dormant state', () { + addTearDown(TugboatReplay.resetForTest); + TugboatReplay.resetForTest(); + + TugboatReplay.deactivate(); + + expect(TugboatReplay.lifecycleState, TugboatLifecycleState.dormant); + }); + + testWidgets('enabled config reaches the active lifecycle state', ( + tester, + ) async { + addTearDown(TugboatReplay.resetForTest); + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => + TugboatReplay.wrapApp(config: _testConfig, child: child!), + home: const Scaffold(body: Text('Enabled')), + ), + ); + await _waitForCaptures(tester); + + expect(TugboatReplay.controller, isNotNull); + expect(TugboatReplay.lifecycleState, TugboatLifecycleState.active); + }); + + testWidgets('capability changes remount the active capture session', ( + tester, + ) async { + addTearDown(TugboatReplay.resetForTest); + final inventoryGrant = ValueNotifier(false); + addTearDown(inventoryGrant.dispose); + + await tester.pumpWidget( + ValueListenableBuilder( + valueListenable: inventoryGrant, + builder: (context, granted, _) => MaterialApp( + builder: (context, child) => TugboatReplay.wrapApp( + config: _testConfig.copyWith(emitSceneInventory: granted), + child: child!, + ), + home: const Scaffold(body: Text('Capability grant')), + ), + ), + ); + await _waitForCaptures(tester); + final initialController = TugboatReplay.controller!; + expect(initialController.config.emitSceneInventory, isFalse); + + inventoryGrant.value = true; + await _waitForCaptures(tester); + final grantedController = TugboatReplay.controller!; + expect(grantedController, isNot(same(initialController))); + expect(grantedController.config.emitSceneInventory, isTrue); + expect(TugboatReplay.lifecycleState, TugboatLifecycleState.active); + + inventoryGrant.value = false; + await _waitForCaptures(tester); + final revokedController = TugboatReplay.controller!; + expect(revokedController, isNot(same(grantedController))); + expect(revokedController.config.emitSceneInventory, isFalse); + expect(TugboatReplay.lifecycleState, TugboatLifecycleState.active); + }); + + testWidgets('runtime capture applies a combined enable and grant rebuild', ( + tester, + ) async { + addTearDown(TugboatReplay.resetForTest); + final upgraded = ValueNotifier(false); + addTearDown(upgraded.dispose); + + await tester.pumpWidget( + ValueListenableBuilder( + valueListenable: upgraded, + builder: (context, value, _) => MaterialApp( + builder: (context, child) => TugboatReplay.wrapApp( + config: _testConfig.copyWith( + enabled: value, + emitSceneInventory: value, + ), + child: child!, + ), + home: const Scaffold(body: Text('Combined grant')), + ), + ), + ); + await tester.pump(); + expect(TugboatReplay.controller, isNull); + + TugboatReplay.activate(activationRequestId: 'combined-grant'); + await _waitForCaptures(tester); + final runtimeController = TugboatReplay.controller!; + expect(runtimeController.config.emitSceneInventory, isFalse); + + upgraded.value = true; + await _waitForCaptures(tester); + final upgradedController = TugboatReplay.controller!; + expect(upgradedController, isNot(same(runtimeController))); + expect(upgradedController.config.emitSceneInventory, isTrue); + expect(TugboatReplay.lifecycleState, TugboatLifecycleState.active); + }); + + testWidgets('disabled config stays inert until activated without rebuild', ( tester, ) async { addTearDown(TugboatReplay.resetForTest); @@ -1367,7 +1510,7 @@ void main() { await tester.pumpWidget( MaterialApp( builder: (context, child) => TugboatReplay.wrapApp( - config: _testConfig.copyWith(profile: TugboatCaptureProfile.dormant), + config: _testConfig.copyWith(enabled: false), child: child!, ), home: const Scaffold(body: Text('Dormant')), @@ -1379,18 +1522,12 @@ void main() { expect(find.text('Dormant'), findsOneWidget); expect(TugboatReplay.boundaryKey.currentContext, isNull); - TugboatReplay.activate( - activationRequestId: 'request-1', - profile: TugboatCaptureProfile.exploration, - ); + TugboatReplay.activate(activationRequestId: 'request-1'); await _waitForCaptures(tester); expect(TugboatReplay.controller, isNotNull); expect(TugboatReplay.activationRequestId, 'request-1'); - expect( - TugboatReplay.controller!.config.profile, - TugboatCaptureProfile.exploration, - ); + expect(TugboatReplay.controller!.config.enabled, isTrue); expect(TugboatReplay.controller!.session!.activationRequestId, 'request-1'); expect(TugboatReplay.controller!.session!.id, isNot(equals('request-1'))); }); @@ -1403,7 +1540,7 @@ void main() { await tester.pumpWidget( MaterialApp( builder: (context, child) => TugboatReplay.wrapApp( - config: _testConfig.copyWith(profile: TugboatCaptureProfile.dormant), + config: _testConfig.copyWith(enabled: false), child: child!, ), home: const Scaffold(body: Text('Gate')), @@ -1411,10 +1548,7 @@ void main() { ); await tester.pump(); - TugboatReplay.activate( - activationRequestId: 'req-a', - profile: TugboatCaptureProfile.exploration, - ); + TugboatReplay.activate(activationRequestId: 'req-a'); await _waitForCaptures(tester); final firstId = TugboatReplay.controller!.session!.id; @@ -1423,16 +1557,35 @@ void main() { await tester.pump(const Duration(milliseconds: 50)); expect(TugboatReplay.controller, isNull); - TugboatReplay.activate( - activationRequestId: 'req-b', - profile: TugboatCaptureProfile.exploration, - ); + TugboatReplay.activate(activationRequestId: 'req-b'); await _waitForCaptures(tester); final secondId = TugboatReplay.controller!.session!.id; expect(secondId, isNot(equals(firstId))); expect(TugboatReplay.activationRequestId, 'req-b'); }); + testWidgets('deactivate keeps an enabled config dormant', (tester) async { + addTearDown(TugboatReplay.resetForTest); + + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => + TugboatReplay.wrapApp(config: _testConfig, child: child!), + home: const Scaffold(body: Text('Enabled config')), + ), + ); + await _waitForCaptures(tester); + expect(TugboatReplay.controller, isNotNull); + + TugboatReplay.deactivate(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(); + + expect(TugboatReplay.controller, isNull); + expect(TugboatReplay.lifecycleState, TugboatLifecycleState.dormant); + }); + testWidgets( 'config userId applies on remount when setUserId was never called', (tester) async { @@ -1480,7 +1633,7 @@ void main() { MaterialApp( builder: (context, child) => TugboatReplay.wrapApp( config: _testConfig.copyWith( - profile: TugboatCaptureProfile.dormant, + enabled: false, userId: userId, collector: collectorConfig(), ), @@ -1493,10 +1646,7 @@ void main() { } await pumpWithUserId('user_a'); - TugboatReplay.activate( - activationRequestId: 'req-user-a', - profile: TugboatCaptureProfile.exploration, - ); + TugboatReplay.activate(activationRequestId: 'req-user-a'); await _waitForCaptures(tester); expect(TugboatReplay.controller!.collectorUserId, 'user_a'); expect(TugboatReplay.hasPendingUserIdOverride, isFalse); @@ -1508,10 +1658,7 @@ void main() { expect(TugboatReplay.hasPendingUserIdOverride, isFalse); await pumpWithUserId('user_b'); - TugboatReplay.activate( - activationRequestId: 'req-user-b', - profile: TugboatCaptureProfile.exploration, - ); + TugboatReplay.activate(activationRequestId: 'req-user-b'); await _waitForCaptures(tester); expect(TugboatReplay.controller!.collectorUserId, 'user_b'); expect(TugboatReplay.hasPendingUserIdOverride, isFalse); @@ -1565,7 +1712,7 @@ void main() { MaterialApp( builder: (context, child) => TugboatReplay.wrapApp( config: _testConfig.copyWith( - profile: TugboatCaptureProfile.dormant, + enabled: false, userId: userId, collector: collectorConfig(), ), @@ -1578,10 +1725,7 @@ void main() { } await pumpWithUserId('user_a'); - TugboatReplay.activate( - activationRequestId: 'req-override-a', - profile: TugboatCaptureProfile.exploration, - ); + TugboatReplay.activate(activationRequestId: 'req-override-a'); await _waitForCaptures(tester); await tester.runAsync(() => TugboatReplay.setUserId('runtime')); expect(TugboatReplay.controller!.collectorUserId, 'runtime'); @@ -1595,10 +1739,7 @@ void main() { expect(TugboatReplay.pendingUserId, 'runtime'); await pumpWithUserId('user_b'); - TugboatReplay.activate( - activationRequestId: 'req-override-b', - profile: TugboatCaptureProfile.exploration, - ); + TugboatReplay.activate(activationRequestId: 'req-override-b'); await _waitForCaptures(tester); expect(TugboatReplay.controller!.collectorUserId, 'runtime'); expect(TugboatReplay.hasPendingUserIdOverride, isTrue); @@ -1610,7 +1751,7 @@ void main() { await tester.pumpWidget( MaterialApp( builder: (context, child) => TugboatReplay.wrapApp( - config: _testConfig.copyWith(profile: TugboatCaptureProfile.dormant), + config: _testConfig.copyWith(enabled: false), child: child!, ), home: const Scaffold(body: Text('Idempotent')), @@ -1618,18 +1759,12 @@ void main() { ); await tester.pump(); - TugboatReplay.activate( - activationRequestId: 'same', - profile: TugboatCaptureProfile.exploration, - ); + TugboatReplay.activate(activationRequestId: 'same'); await _waitForCaptures(tester); final sessionId = TugboatReplay.controller!.session!.id; final epoch = TugboatReplay.lifecycle.requestEpoch; - TugboatReplay.activate( - activationRequestId: 'same', - profile: TugboatCaptureProfile.exploration, - ); + TugboatReplay.activate(activationRequestId: 'same'); await tester.pump(); expect(TugboatReplay.lifecycle.requestEpoch, epoch); expect(TugboatReplay.controller!.session!.id, sessionId); diff --git a/sdks/flutter/packages/tugboat/test/viewport_semantic_map_test.dart b/sdks/flutter/packages/tugboat/test/viewport_semantic_map_test.dart index 1a4acfb..21ba781 100644 --- a/sdks/flutter/packages/tugboat/test/viewport_semantic_map_test.dart +++ b/sdks/flutter/packages/tugboat/test/viewport_semantic_map_test.dart @@ -5,7 +5,11 @@ import 'package:tugboat/src/anchors.dart'; import 'package:tugboat/src/viewport_semantic_session.dart'; const _semanticMapConfig = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -14,7 +18,11 @@ const _semanticMapConfig = TugboatReplayConfig( ); const _semanticMapConfigWithLogs = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -23,7 +31,11 @@ const _semanticMapConfigWithLogs = TugboatReplayConfig( ); const _scrollSemanticMapConfig = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, + emitSceneInventory: true, + emitViewportSemanticMap: true, + emitCaptureDiagnostics: true, + acceptActionContext: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -342,14 +354,14 @@ void main() { expect(interaction.data['targetFingerprint'], isNotEmpty); }); - testWidgets('dormant profile stays off with default semantic mode', ( + testWidgets('disabled capture stays off with default semantic mode', ( tester, ) async { await tester.pumpWidget( MaterialApp( builder: (context, child) => TugboatReplay.wrapApp( config: const TugboatReplayConfig( - profile: TugboatCaptureProfile.dormant, + enabled: false, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -383,14 +395,14 @@ void main() { await tester.pump(); }); - testWidgets('production default resolves taps without emitting maps', ( + testWidgets('default capture resolves taps without emitting maps', ( tester, ) async { await tester.pumpWidget( MaterialApp( builder: (context, child) => TugboatReplay.wrapApp( config: const TugboatReplayConfig( - profile: TugboatCaptureProfile.productionLean, + enabled: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -430,13 +442,13 @@ void main() { }); testWidgets( - 'production semantic map emission stays off even with full mode', + 'semantic map emission stays off without capability even with full mode', (tester) async { await tester.pumpWidget( MaterialApp( builder: (context, child) => TugboatReplay.wrapApp( config: const TugboatReplayConfig( - profile: TugboatCaptureProfile.productionLean, + enabled: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -481,14 +493,13 @@ void main() { }, ); - testWidgets('production semantic map stays off with default mode', ( - tester, - ) async { + testWidgets('semantic map stays off with default mode', (tester) async { await tester.pumpWidget( MaterialApp( builder: (context, child) => TugboatReplay.wrapApp( config: const TugboatReplayConfig( - profile: TugboatCaptureProfile.productionLean, + enabled: true, + emitViewportSemanticMap: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false, @@ -620,7 +631,7 @@ void main() { }); testWidgets( - 'settled exploration screen emits both scene_inventory and viewport_semantic_map', + 'settled capable screen emits scene inventory and viewport semantic map', (tester) async { await _pumpSettledScreen( tester, @@ -727,6 +738,31 @@ void main() { }, ); + testWidgets('semantic-map capability does not emit raw inventory', ( + tester, + ) async { + await _pumpSettledScreen( + tester, + Scaffold( + body: FilledButton(onPressed: () {}, child: const Text('Go')), + ), + config: const TugboatReplayConfig( + enabled: true, + settleDelay: Duration.zero, + enableGlobalPointerCapture: false, + emitViewportSemanticMap: true, + viewportSemanticMode: TugboatViewportSemanticMode.full, + ), + ); + + final events = TugboatReplay.controller!.session!.events; + expect(events.where((event) => event.type == 'scene_inventory'), isEmpty); + expect( + events.where((event) => event.type == 'viewport_semantic_map'), + isNotEmpty, + ); + }); + testWidgets('debug log config does not change emitted payload shape', ( tester, ) async { diff --git a/sdks/flutter/packages/tugboat/test/viewport_semantic_policy_test.dart b/sdks/flutter/packages/tugboat/test/viewport_semantic_policy_test.dart index a648d38..70dba98 100644 --- a/sdks/flutter/packages/tugboat/test/viewport_semantic_policy_test.dart +++ b/sdks/flutter/packages/tugboat/test/viewport_semantic_policy_test.dart @@ -2,80 +2,49 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:tugboat/tugboat.dart'; void main() { - test('resolveViewportSemanticPolicy covers profile × mode matrix', () { + test('resolveViewportSemanticPolicy uses explicit event capability', () { expect( - resolveViewportSemanticPolicy( - profile: TugboatCaptureProfile.dormant, - mode: TugboatViewportSemanticMode.full, - ), + resolveViewportSemanticPolicy(mode: TugboatViewportSemanticMode.off), TugboatViewportSemanticPolicy.off, ); - final explorationFull = resolveViewportSemanticPolicy( - profile: TugboatCaptureProfile.exploration, + final full = resolveViewportSemanticPolicy( mode: TugboatViewportSemanticMode.full, + emitEvents: true, ); - expect(explorationFull.engineEnabled, isTrue); - expect(explorationFull.emitEvents, isTrue); - expect(explorationFull.debugLogs, isFalse); - expect(explorationFull.holdPersistentSemanticsHandle, isTrue); + expect(full.engineEnabled, isTrue); + expect(full.emitEvents, isTrue); + expect(full.debugLogs, isFalse); + expect(full.holdPersistentSemanticsHandle, isTrue); - final explorationDebug = resolveViewportSemanticPolicy( - profile: TugboatCaptureProfile.exploration, + final debug = resolveViewportSemanticPolicy( mode: TugboatViewportSemanticMode.fullWithDebugLogs, + emitEvents: true, ); - expect(explorationDebug.debugLogs, isTrue); - expect(explorationDebug.emitEvents, isTrue); + expect(debug.debugLogs, isTrue); + expect(debug.emitEvents, isTrue); - final explorationTapOnly = resolveViewportSemanticPolicy( - profile: TugboatCaptureProfile.exploration, + final tapOnly = resolveViewportSemanticPolicy( mode: TugboatViewportSemanticMode.tapResolutionOnly, + emitEvents: true, ); - expect(explorationTapOnly.engineEnabled, isTrue); - expect(explorationTapOnly.emitEvents, isFalse); + expect(tapOnly.engineEnabled, isTrue); + expect(tapOnly.emitEvents, isFalse); + expect(tapOnly.holdPersistentSemanticsHandle, isFalse); - final productionTapOnly = resolveViewportSemanticPolicy( - profile: TugboatCaptureProfile.productionLean, - mode: TugboatViewportSemanticMode.tapResolutionOnly, - ); - expect(productionTapOnly.engineEnabled, isTrue); - expect(productionTapOnly.emitEvents, isFalse); - expect(productionTapOnly.holdPersistentSemanticsHandle, isFalse); - - final productionFull = resolveViewportSemanticPolicy( - profile: TugboatCaptureProfile.productionLean, - mode: TugboatViewportSemanticMode.full, - ); - expect(productionFull.engineEnabled, isTrue); - expect(productionFull.emitEvents, isFalse); - expect(productionFull.debugLogs, isFalse); - expect(productionFull.holdPersistentSemanticsHandle, isFalse); - - final productionDebug = resolveViewportSemanticPolicy( - profile: TugboatCaptureProfile.productionLean, - mode: TugboatViewportSemanticMode.fullWithDebugLogs, - ); - expect(productionDebug.engineEnabled, isTrue); - expect(productionDebug.emitEvents, isFalse); - expect(productionDebug.debugLogs, isFalse); - expect(productionDebug.holdPersistentSemanticsHandle, isFalse); - - final productionDefault = const TugboatReplayConfig( - profile: TugboatCaptureProfile.productionLean, + final defaultPolicy = const TugboatReplayConfig( + enabled: true, ).viewportSemanticPolicy; - expect(productionDefault.engineEnabled, isTrue); - expect(productionDefault.emitEvents, isFalse); - expect(productionDefault.holdPersistentSemanticsHandle, isFalse); + expect(defaultPolicy.engineEnabled, isTrue); + expect(defaultPolicy.emitEvents, isFalse); + expect(defaultPolicy.holdPersistentSemanticsHandle, isFalse); }); test('replay config carries userId through copyWith', () { const config = TugboatReplayConfig(userId: 'user_1'); expect(config.userId, 'user_1'); - expect( - config.copyWith(profile: TugboatCaptureProfile.productionLean).userId, - 'user_1', - ); + expect(config.copyWith(enabled: true).userId, 'user_1'); expect(config.copyWith(userId: 'user_2').userId, 'user_2'); }); } diff --git a/sdks/flutter/packages/tugboat_dio/CHANGELOG.md b/sdks/flutter/packages/tugboat_dio/CHANGELOG.md index 03bdb59..3f2e972 100644 --- a/sdks/flutter/packages/tugboat_dio/CHANGELOG.md +++ b/sdks/flutter/packages/tugboat_dio/CHANGELOG.md @@ -1,3 +1,10 @@ +## 0.9.0 + +### Changed + +- Compatibility release for the mode-free `tugboat` 0.9.0 API. The Dio + adapter has no runtime behavior change. + ## 0.8.17 ### Changed diff --git a/sdks/flutter/packages/tugboat_dio/pubspec.yaml b/sdks/flutter/packages/tugboat_dio/pubspec.yaml index bf520a3..01fd219 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.8.17 +version: 0.9.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.8.17 + tugboat: ^0.9.0 dev_dependencies: flutter_lints: ^5.0.0 diff --git a/sdks/flutter/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart b/sdks/flutter/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart index 2febac5..f1c92e4 100644 --- a/sdks/flutter/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart +++ b/sdks/flutter/packages/tugboat_dio/test/tugboat_dio_interceptor_test.dart @@ -8,7 +8,7 @@ import 'package:tugboat/tugboat.dart'; import 'package:tugboat_dio/tugboat_dio.dart'; const _testConfig = TugboatReplayConfig( - profile: TugboatCaptureProfile.exploration, + enabled: true, settleDelay: Duration.zero, interactionClaimWindow: Duration.zero, enableGlobalPointerCapture: false,