Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/tugboat/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

### Added

- **Replay coherence characterization harness** — deterministic, advanceable
scheduler/capture test seams (`debugNow`, `debugDelay`, `debugExecuteCapture`,
`debugSeedFrame`) plus reusable helpers that reproduce known navigation/frame
races without wall-clock sleeps. Tracks milestone issue #5.
- **Pathless-tap snap** (2026-07-04) — when hit-testing resolves a tap to a target
with a role but no canonical path (opaque `Texture`, decorated boxes outside the
token map), the tap is re-anchored to the smallest *interactive* scene-inventory
Expand Down
124 changes: 115 additions & 9 deletions packages/tugboat/lib/src/controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -259,18 +259,95 @@ class TugboatReplayController extends ChangeNotifier {
_currentStateAnchor = anchor;
}

/// When true, [_refreshStateAnchor] keeps the last planted state instead of
/// rebuilding from the widget tree. Characterization tests use this when
/// driving the controller without a mounted scene.
@visibleForTesting
bool debugFreezeStateAnchor = false;

@visibleForTesting
void debugSetExplorationFramesSuppressed(bool suppressed) {
_explorationFramesSuppressed = suppressed;
}

/// Test-only clock for capture scheduling. Defaults to [DateTime.now].
@visibleForTesting
DateTime Function()? debugNow;

/// Test-only delay primitive used by route and capture waits.
///
/// Defaults to [Future.delayed]. Harnesses should make each delay
/// explicitly advanceable so tests never rely on wall-clock sleeps.
@visibleForTesting
Future<void> Function(Duration duration)? debugDelay;

/// Test-only capture executor. When set, replaces screenshot readback while
/// preserving the production request/queue/pending-route control flow.
@visibleForTesting
Future<String?> Function({
required TugboatFrameTrigger trigger,
required bool force,
})?
debugExecuteCapture;

@visibleForTesting
int get debugRouteEpoch => _routeEpoch;

@visibleForTesting
bool get debugRouteCapturePending => _routeCapturePending;

@visibleForTesting
bool get debugCaptureInFlight => _captureInFlight;

@visibleForTesting
Future<void> drainPointerQueue() => _queue;

@visibleForTesting
Future<void> debugEnqueueTask(String label, Future<void> Function() task) =>
_enqueue(label, task);

/// Plants a synthetic frame for characterization tests that drive the
/// controller without real screenshot readback.
@visibleForTesting
String debugSeedFrame({
String? contentHash,
TugboatFrameTrigger trigger = TugboatFrameTrigger.manual,
int width = 10,
int height = 10,
}) {
final session = _session;
if (session == null) {
throw StateError('debugSeedFrame requires an active session');
}
final frameId = _nextId('frame');
final hash = contentHash ?? 'hash-$frameId';
final frame = TugboatFrame(
id: frameId,
atMs: atMs,
width: width,
height: height,
contentHash: hash,
trigger: trigger,
byteLength: 0,
captureSessionId: session.id,
);
session.frames.add(frame);
session.frameBytes[frameId] = Uint8List(0);
_hashToFrameId[hash] = frameId;
_latestFrameId = frameId;
return frameId;
}

DateTime _now() => debugNow?.call() ?? DateTime.now();

Future<void> _delay(Duration duration) {
// Preserve Future.delayed(Duration.zero) yielding semantics; never sync-complete.
final effective = duration < Duration.zero ? Duration.zero : duration;
final override = debugDelay;
if (override != null) return override(effective);
return Future<void>.delayed(effective);
}

/// Serializes [task] on the controller queue while guaranteeing that a
/// failure in one task never poisons the chain: an uncaught error in a
/// plain `_queue.then(...)` would turn `_queue` into an errored future and
Expand Down Expand Up @@ -530,6 +607,7 @@ class TugboatReplayController extends ChangeNotifier {
}

TugboatStateAnchor? _refreshStateAnchor() {
if (debugFreezeStateAnchor) return _currentStateAnchor;
final resolver = _anchorResolver;
if (resolver == null) return _currentStateAnchor;
final keyboardOpen = _isKeyboardOpen();
Expand Down Expand Up @@ -569,7 +647,7 @@ class TugboatReplayController extends ChangeNotifier {
}

final delay = settleDelay ?? config.settleDelay;
final notBefore = DateTime.now().add(delay);
final notBefore = _now().add(delay);
final completer = Completer<String?>();
final incoming = _ScheduledCapture(
trigger: trigger,
Expand Down Expand Up @@ -598,9 +676,9 @@ class TugboatReplayController extends ChangeNotifier {
_capturePumpScheduled = false;
while (!_disposed && _scheduledCapture != null) {
final scheduled = _scheduledCapture!;
final wait = scheduled.notBefore.difference(DateTime.now());
final wait = scheduled.notBefore.difference(_now());
if (wait > Duration.zero) {
await Future<void>.delayed(wait);
await _delay(wait);
}
if (_disposed) break;

Expand Down Expand Up @@ -636,14 +714,43 @@ class TugboatReplayController extends ChangeNotifier {

Future<void> _waitForCaptureIdle() async {
while (_captureInFlight && !_disposed) {
await Future<void>.delayed(const Duration(milliseconds: 16));
await _delay(const Duration(milliseconds: 16));
}
}

Future<String?> _executeCapture({
required TugboatFrameTrigger trigger,
bool force = false,
}) async {
final captureOverride = debugExecuteCapture;
if (captureOverride != null) {
if (_disposed ||
_capturePaused ||
_skipCapture ||
_shouldSuppressFrameCapture ||
_captureInFlight) {
return _latestFrameId;
}
_captureInFlight = true;
try {
// Match the production capture path: refresh state before capture and
// emit inventory after, so the override seam does not leave anchors
// stale relative to real screenshot execution.
_refreshStateAnchor();
final frameId = await captureOverride(trigger: trigger, force: force);
if (frameId != null) {
_latestFrameId = frameId;
}
_maybeEmitSceneInventory();
return frameId ?? _latestFrameId;
} finally {
_captureInFlight = false;
if (_scheduledCapture != null) {
_ensureCapturePumpScheduled();
}
}
}
Comment thread
Chinmay-KB marked this conversation as resolved.

if (_disposed ||
_capturePaused ||
_skipCapture ||
Expand Down Expand Up @@ -676,8 +783,9 @@ class TugboatReplayController extends ChangeNotifier {

final queueStarted = DateTime.now();
await capturer.waitForFrameBudget();
final queueWaitMicros =
DateTime.now().difference(queueStarted).inMicroseconds;
final queueWaitMicros = DateTime.now()
.difference(queueStarted)
.inMicroseconds;
if (_disposed || _capturePaused || _skipCapture) return _latestFrameId;
_refreshStateAnchor();
final signature = _currentStateAnchor?.signature ?? '';
Expand Down Expand Up @@ -1292,9 +1400,7 @@ class TugboatReplayController extends ChangeNotifier {
: config.settleDelay;
return _enqueue('route_change', () async {
try {
await Future<void>.delayed(
transition.transitionDuration + postRouteSettle,
);
await _delay(transition.transitionDuration + postRouteSettle);
_skipCapture = false;
if (_disposed) return;
if (epoch != _routeEpoch) return;
Expand Down
2 changes: 1 addition & 1 deletion packages/tugboat/lib/src/sdk_version.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// Keep this in sync with packages/tugboat/pubspec.yaml. The SDK version test
// reads pubspec.yaml directly so release bumps fail fast if this drifts.
const tugboatSdkVersion = '0.4.0';
const tugboatSdkVersion = '0.4.1';
2 changes: 1 addition & 1 deletion packages/tugboat/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: tugboat
description: >-
Screenshot-based session replay with compact interaction anchors for Tugboat.
version: 0.4.0
version: 0.4.1
repository: https://github.com/blendto/tugboat-flutter
issue_tracker: https://github.com/blendto/tugboat-flutter/issues
homepage: https://github.com/blendto/tugboat-flutter
Expand Down
Loading
Loading