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
8 changes: 8 additions & 0 deletions packages/tugboat/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
## 0.4.18

### Fixed

- **Semantic parameter pairs in overlays** — interactions in dialogs and
popovers now retain the accessibility label and raw value supplied by the
visible control, rather than metadata from an obscured control beneath it.

## 0.4.17

### Changed
Expand Down
2 changes: 1 addition & 1 deletion packages/tugboat/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ 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.4.17`. Session JSON uses schema version `8`
The current package version is `0.4.18`. Session JSON uses schema version `8`
(readers still accept `6`), and structural fingerprints use fingerprint schema
version `6`.

Expand Down
23 changes: 23 additions & 0 deletions packages/tugboat/example/lib/screens/profile_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
bool _darkMode = false;
double _notificationVolume = 0.6;
String _language = 'English';
int _generationCount = 1;

@override
Widget build(BuildContext context) {
Expand Down Expand Up @@ -143,6 +144,28 @@ class _ProfileScreenState extends State<ProfileScreen> {
),
],
),
DemoSection(
title: 'Generation settings',
children: [
Wrap(
spacing: 8,
children: [
for (final count in [1, 2, 3, 4])
Semantics(
label: 'Number of generations',
value: count.toString(),
selected: _generationCount == count,
child: FilledButton(
key: Key('generation-count-$count'),
onPressed: () =>
setState(() => _generationCount = count),
child: Text(count.toString()),
),
),
],
),
],
),
DemoSection(
title: 'Account actions',
children: [
Expand Down
51 changes: 50 additions & 1 deletion packages/tugboat/example/test/widget_test.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,23 @@
import 'package:tugboat_example/main.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:tugboat/tugboat.dart';
import 'package:tugboat_example/main.dart';

Future<void> _waitForTugboatEvents(WidgetTester tester) async {
for (var attempt = 0; attempt < 12; attempt++) {
await tester.pump(const Duration(milliseconds: 50));
}
}

Map<String, Object?> _semanticAnnotation(TugboatEvent event) {
final raw = event.data['semanticAnnotation'];
return Map<String, Object?>.from(raw! as Map);
}

void main() {
setUp(TugboatReplay.resetForTest);
tearDown(TugboatReplay.resetForTest);

testWidgets('demo app loads home screen', (tester) async {
await tester.pumpWidget(const ReplayDemoApp());
await tester.pump();
Expand All @@ -12,4 +28,37 @@ void main() {
await tester.pump(const Duration(milliseconds: 300));
}
});

testWidgets(
'generation count tap emits its semantic parameter label and value',
(tester) async {
await tester.binding.setSurfaceSize(const Size(800, 1200));
addTearDown(() => tester.binding.setSurfaceSize(null));
TugboatReplay.activate(
activationRequestId: 'example-semantic-parameter-test',
profile: TugboatCaptureProfile.productionLean,
);
await tester.pumpWidget(const ReplayDemoApp());
await _waitForTugboatEvents(tester);

await tester.tap(find.text('Profile & settings'));
await tester.pumpAndSettle();
await _waitForTugboatEvents(tester);

await tester.tap(find.byKey(const Key('generation-count-3')));
await _waitForTugboatEvents(tester);

final tap = TugboatReplay.controller!.session!.events.lastWhere(
(event) => event.type == 'tap',
);
final annotation = _semanticAnnotation(tap);

expect(annotation['label'], {
'kind': 'string',
'value': 'Number of generations',
});
expect(annotation['value'], {'kind': 'string', 'value': '3'});
expect(annotation['selected'], isFalse);
},
);
}
40 changes: 36 additions & 4 deletions packages/tugboat/lib/src/anchor_resolver.dart
Original file line number Diff line number Diff line change
Expand Up @@ -257,16 +257,32 @@ class AnchorResolver {
if (controlValue != null && semanticAnnotation != null) break;
}

if (controlValue == null || semanticAnnotation == null) {
final localSemanticPair =
semanticAnnotation?.label != null && semanticAnnotation?.value != null;
if (controlValue == null || !localSemanticPair) {
// An overlay can sit outside this capture boundary while the global
// semantics tree still contains the actual control at the tap point.
// Only inspect that tree when local hit-test metadata is incomplete:
// flushing and walking it is comparatively expensive for every tap.
final hits = _semanticsNodesAt(
globalPosition: globalPosition,
rootContext: rootContext,
rootRender: rootRender,
);
if (controlValue == null && hits.isNotEmpty) {
controlValue = tugboatControlValueFromSemanticsNode(hits.last);
final semanticFromHits = _semanticAnnotationFromHits(hits);
final semanticPair =
semanticFromHits?.label != null && semanticFromHits?.value != null;
if (semanticAnnotation == null || (!localSemanticPair && semanticPair)) {
semanticAnnotation = semanticFromHits ?? semanticAnnotation;
}

final controlFromHits = _controlValueFromSemanticsHits(hits);
if (controlValue == null ||
(!localSemanticPair &&
semanticPair &&
controlValue.sources.contains('semantics'))) {
controlValue = controlFromHits ?? controlValue;
}
Comment on lines +260 to 285
semanticAnnotation ??= _semanticAnnotationFromHits(hits);
}

return TugboatInteractionMetadata._(
Expand Down Expand Up @@ -350,6 +366,22 @@ class AnchorResolver {
return merged;
}

TugboatControlValue? _controlValueFromSemanticsHits(
List<SemanticsNode> hits,
) {
TugboatControlValue? fallback;
for (final node in hits.reversed) {
final value = tugboatControlValueFromSemanticsNode(node);
if (value == null) continue;
fallback ??= value;
final annotation = tugboatSemanticAnnotationFromNode(node);
if (annotation?.label != null && annotation?.value != null) {
return value;
}
}
return fallback;
}

List<SemanticsNode> _semanticsNodesAt({
required Offset globalPosition,
required Element rootContext,
Expand Down
10 changes: 9 additions & 1 deletion packages/tugboat/lib/src/control_value.dart
Original file line number Diff line number Diff line change
Expand Up @@ -407,16 +407,24 @@ TugboatSemanticAnnotation? tugboatSemanticAnnotationFromNode(
}

/// Merges two annotations, preferring [primary] fields and filling gaps.
///
/// An ancestor that supplies both a label and a value semantically describes a
/// parameter/value pair. Keep that label as the parameter identity instead of
/// replacing it with the descendant button's visible value text.
TugboatSemanticAnnotation tugboatMergeSemanticAnnotations(
TugboatSemanticAnnotation primary,
TugboatSemanticAnnotation fallback,
) {
final fallbackDescribesParameter =
fallback.label != null && fallback.value != null;
return TugboatSemanticAnnotation(
role: (primary.role != null && primary.role!.isNotEmpty)
? primary.role
: fallback.role,
identifier: primary.identifier ?? fallback.identifier,
label: primary.label ?? fallback.label,
label: fallbackDescribesParameter
? fallback.label
: primary.label ?? fallback.label,
value: primary.value ?? fallback.value,
selected: primary.selected ?? fallback.selected,
checked: primary.checked ?? fallback.checked,
Expand Down
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.17
version: 0.4.18
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
49 changes: 49 additions & 0 deletions packages/tugboat/test/tugboat_replay_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,55 @@ bool _containsLabelTelemetry(Object? value) {
}

void main() {
testWidgets('popup option emits its semantic parameter pair', (tester) async {
addTearDown(TugboatReplay.resetForTest);

await tester.pumpWidget(
MaterialApp(
builder: (context, child) =>
TugboatReplay.wrapApp(config: _testConfig, child: child!),
home: Builder(
builder: (context) => Scaffold(
body: FilledButton(
onPressed: () => showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
content: Semantics(
container: true,
excludeSemantics: true,
label: 'Image quality',
value: '2K',
button: true,
onTap: () => Navigator.pop(dialogContext),
child: TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('2K • Advanced'),
),
),
),
),
child: const Text('Choose quality'),
),
),
),
),
);
await tester.pump();
await tester.tap(find.text('Choose quality'));
await tester.pumpAndSettle();
await tester.tap(find.text('2K • Advanced'));
await tester.pump();

final tap = TugboatReplay.controller!.session!.events
.where((event) => event.type == 'tap')
.last;
final semantic = Map<String, Object?>.from(
tap.data['semanticAnnotation'] as Map,
);
expect(semantic['label'], {'kind': 'string', 'value': 'Image quality'});
expect(semantic['value'], {'kind': 'string', 'value': '2K'});
});

testWidgets('detached lifecycle emits session_end once', (tester) async {
await tester.pumpWidget(
MaterialApp(
Expand Down
Loading