Skip to content

Commit d5992b7

Browse files
authored
Merge PR #15: test replay coherence characterization harness
test: add replay coherence characterization harness (#5)
2 parents f67e4e0 + 92dbf28 commit d5992b7

6 files changed

Lines changed: 1565 additions & 11 deletions

File tree

packages/tugboat/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
### Added
44

5+
- **Replay coherence characterization harness** — deterministic, advanceable
6+
scheduler/capture test seams (`debugNow`, `debugDelay`, `debugExecuteCapture`,
7+
`debugSeedFrame`) plus reusable helpers that reproduce known navigation/frame
8+
races without wall-clock sleeps. Tracks milestone issue #5.
59
- **Pathless-tap snap** (2026-07-04) — when hit-testing resolves a tap to a target
610
with a role but no canonical path (opaque `Texture`, decorated boxes outside the
711
token map), the tap is re-anchored to the smallest *interactive* scene-inventory

packages/tugboat/lib/src/controller.dart

Lines changed: 115 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -259,18 +259,95 @@ class TugboatReplayController extends ChangeNotifier {
259259
_currentStateAnchor = anchor;
260260
}
261261

262+
/// When true, [_refreshStateAnchor] keeps the last planted state instead of
263+
/// rebuilding from the widget tree. Characterization tests use this when
264+
/// driving the controller without a mounted scene.
265+
@visibleForTesting
266+
bool debugFreezeStateAnchor = false;
267+
262268
@visibleForTesting
263269
void debugSetExplorationFramesSuppressed(bool suppressed) {
264270
_explorationFramesSuppressed = suppressed;
265271
}
266272

273+
/// Test-only clock for capture scheduling. Defaults to [DateTime.now].
274+
@visibleForTesting
275+
DateTime Function()? debugNow;
276+
277+
/// Test-only delay primitive used by route and capture waits.
278+
///
279+
/// Defaults to [Future.delayed]. Harnesses should make each delay
280+
/// explicitly advanceable so tests never rely on wall-clock sleeps.
281+
@visibleForTesting
282+
Future<void> Function(Duration duration)? debugDelay;
283+
284+
/// Test-only capture executor. When set, replaces screenshot readback while
285+
/// preserving the production request/queue/pending-route control flow.
286+
@visibleForTesting
287+
Future<String?> Function({
288+
required TugboatFrameTrigger trigger,
289+
required bool force,
290+
})?
291+
debugExecuteCapture;
292+
293+
@visibleForTesting
294+
int get debugRouteEpoch => _routeEpoch;
295+
296+
@visibleForTesting
297+
bool get debugRouteCapturePending => _routeCapturePending;
298+
299+
@visibleForTesting
300+
bool get debugCaptureInFlight => _captureInFlight;
301+
267302
@visibleForTesting
268303
Future<void> drainPointerQueue() => _queue;
269304

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

309+
/// Plants a synthetic frame for characterization tests that drive the
310+
/// controller without real screenshot readback.
311+
@visibleForTesting
312+
String debugSeedFrame({
313+
String? contentHash,
314+
TugboatFrameTrigger trigger = TugboatFrameTrigger.manual,
315+
int width = 10,
316+
int height = 10,
317+
}) {
318+
final session = _session;
319+
if (session == null) {
320+
throw StateError('debugSeedFrame requires an active session');
321+
}
322+
final frameId = _nextId('frame');
323+
final hash = contentHash ?? 'hash-$frameId';
324+
final frame = TugboatFrame(
325+
id: frameId,
326+
atMs: atMs,
327+
width: width,
328+
height: height,
329+
contentHash: hash,
330+
trigger: trigger,
331+
byteLength: 0,
332+
captureSessionId: session.id,
333+
);
334+
session.frames.add(frame);
335+
session.frameBytes[frameId] = Uint8List(0);
336+
_hashToFrameId[hash] = frameId;
337+
_latestFrameId = frameId;
338+
return frameId;
339+
}
340+
341+
DateTime _now() => debugNow?.call() ?? DateTime.now();
342+
343+
Future<void> _delay(Duration duration) {
344+
// Preserve Future.delayed(Duration.zero) yielding semantics; never sync-complete.
345+
final effective = duration < Duration.zero ? Duration.zero : duration;
346+
final override = debugDelay;
347+
if (override != null) return override(effective);
348+
return Future<void>.delayed(effective);
349+
}
350+
274351
/// Serializes [task] on the controller queue while guaranteeing that a
275352
/// failure in one task never poisons the chain: an uncaught error in a
276353
/// plain `_queue.then(...)` would turn `_queue` into an errored future and
@@ -530,6 +607,7 @@ class TugboatReplayController extends ChangeNotifier {
530607
}
531608

532609
TugboatStateAnchor? _refreshStateAnchor() {
610+
if (debugFreezeStateAnchor) return _currentStateAnchor;
533611
final resolver = _anchorResolver;
534612
if (resolver == null) return _currentStateAnchor;
535613
final keyboardOpen = _isKeyboardOpen();
@@ -569,7 +647,7 @@ class TugboatReplayController extends ChangeNotifier {
569647
}
570648

571649
final delay = settleDelay ?? config.settleDelay;
572-
final notBefore = DateTime.now().add(delay);
650+
final notBefore = _now().add(delay);
573651
final completer = Completer<String?>();
574652
final incoming = _ScheduledCapture(
575653
trigger: trigger,
@@ -598,9 +676,9 @@ class TugboatReplayController extends ChangeNotifier {
598676
_capturePumpScheduled = false;
599677
while (!_disposed && _scheduledCapture != null) {
600678
final scheduled = _scheduledCapture!;
601-
final wait = scheduled.notBefore.difference(DateTime.now());
679+
final wait = scheduled.notBefore.difference(_now());
602680
if (wait > Duration.zero) {
603-
await Future<void>.delayed(wait);
681+
await _delay(wait);
604682
}
605683
if (_disposed) break;
606684

@@ -636,14 +714,43 @@ class TugboatReplayController extends ChangeNotifier {
636714

637715
Future<void> _waitForCaptureIdle() async {
638716
while (_captureInFlight && !_disposed) {
639-
await Future<void>.delayed(const Duration(milliseconds: 16));
717+
await _delay(const Duration(milliseconds: 16));
640718
}
641719
}
642720

643721
Future<String?> _executeCapture({
644722
required TugboatFrameTrigger trigger,
645723
bool force = false,
646724
}) async {
725+
final captureOverride = debugExecuteCapture;
726+
if (captureOverride != null) {
727+
if (_disposed ||
728+
_capturePaused ||
729+
_skipCapture ||
730+
_shouldSuppressFrameCapture ||
731+
_captureInFlight) {
732+
return _latestFrameId;
733+
}
734+
_captureInFlight = true;
735+
try {
736+
// Match the production capture path: refresh state before capture and
737+
// emit inventory after, so the override seam does not leave anchors
738+
// stale relative to real screenshot execution.
739+
_refreshStateAnchor();
740+
final frameId = await captureOverride(trigger: trigger, force: force);
741+
if (frameId != null) {
742+
_latestFrameId = frameId;
743+
}
744+
_maybeEmitSceneInventory();
745+
return frameId ?? _latestFrameId;
746+
} finally {
747+
_captureInFlight = false;
748+
if (_scheduledCapture != null) {
749+
_ensureCapturePumpScheduled();
750+
}
751+
}
752+
}
753+
647754
if (_disposed ||
648755
_capturePaused ||
649756
_skipCapture ||
@@ -676,8 +783,9 @@ class TugboatReplayController extends ChangeNotifier {
676783

677784
final queueStarted = DateTime.now();
678785
await capturer.waitForFrameBudget();
679-
final queueWaitMicros =
680-
DateTime.now().difference(queueStarted).inMicroseconds;
786+
final queueWaitMicros = DateTime.now()
787+
.difference(queueStarted)
788+
.inMicroseconds;
681789
if (_disposed || _capturePaused || _skipCapture) return _latestFrameId;
682790
_refreshStateAnchor();
683791
final signature = _currentStateAnchor?.signature ?? '';
@@ -1292,9 +1400,7 @@ class TugboatReplayController extends ChangeNotifier {
12921400
: config.settleDelay;
12931401
return _enqueue('route_change', () async {
12941402
try {
1295-
await Future<void>.delayed(
1296-
transition.transitionDuration + postRouteSettle,
1297-
);
1403+
await _delay(transition.transitionDuration + postRouteSettle);
12981404
_skipCapture = false;
12991405
if (_disposed) return;
13001406
if (epoch != _routeEpoch) return;
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
// Keep this in sync with packages/tugboat/pubspec.yaml. The SDK version test
22
// reads pubspec.yaml directly so release bumps fail fast if this drifts.
3-
const tugboatSdkVersion = '0.4.0';
3+
const tugboatSdkVersion = '0.4.1';

packages/tugboat/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: tugboat
22
description: >-
33
Screenshot-based session replay with compact interaction anchors for Tugboat.
4-
version: 0.4.0
4+
version: 0.4.1
55
repository: https://github.com/blendto/tugboat-flutter
66
issue_tracker: https://github.com/blendto/tugboat-flutter/issues
77
homepage: https://github.com/blendto/tugboat-flutter

0 commit comments

Comments
 (0)