Files
buzz/mobile/test/features/pairing/pairing_page_test.dart
klopez4212 ce94656b0d fix(mobile): use join template for community creation (#9072)
Community creation now uses the same mobile template, borderless iOS
input, and keyboard-coordinated transition as joining, replacing the old
welcome screen and boxed field. Name validation, separate form drafts,
reduced motion, and accessible error announcements are preserved.

Validation: analysis and 4,407 Flutter tests pass (45 skipped, four
workers); independent review passed. Kenny approved the simulator
preview. Agent verification of the full native transition remains
pending; the after snapshot below is a Flutter widget render.

| Before | After |
| --- | --- |
| ![Previous create
screen](https://github.com/user-attachments/assets/353b3b41-57b4-4a3a-9c21-10ea1c0becf2)
| ![Create using the join
template](https://github.com/user-attachments/assets/5a0d9d8d-cace-44b1-a3c8-bbe65f04a2e0)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-04 20:32:55 +00:00

1209 lines
40 KiB
Dart

import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:local_auth/local_auth.dart';
import 'package:buzz/shared/theme/buzz_icons.dart';
import 'package:buzz/features/pairing/pairing_page.dart';
import 'package:buzz/features/pairing/pairing_page/onboarding_wordmark.dart';
import 'package:buzz/features/pairing/pairing_provider.dart';
import 'package:buzz/shared/community/community.dart';
import 'package:buzz/shared/security/sensitive_action_authorizer.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/buzz_loading_indicator.dart';
import '../../helpers/widget_helpers.dart';
void main() {
group('PairingPage', () {
testWidgets('create reuses join form, motion, validation and Back', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
await tester.binding.setSurfaceSize(const Size(393, 852));
addTearDown(() => tester.binding.setSurfaceSize(null));
for (final reduced in [false, true]) {
String? submitted;
await tester.pumpWidget(
WidgetHelpers.testable(
disableAnimations: reduced,
child: HookBuilder(
builder: (context) {
final creating = useState(false);
final joining = useState(false);
return PairingPage(
connectionLanding: true,
signedInOnboarding: true,
creatingCommunity: creating.value,
enteringCommunityLink: joining.value,
onCreateCommunity: () => creating.value = true,
onCommunityLink: () => joining.value = true,
onSubmitCommunity: (name) => submitted = name,
onWelcomeBack: () {
creating.value = false;
joining.value = false;
},
);
},
),
),
);
await tester.pumpAndSettle();
final action = find.byKey(const Key('add-community-link-action'));
final originalAction = tester.element(action);
await tester.tap(find.text('Create a community'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
expect(tester.element(action), same(originalAction));
expect(find.bySemanticsLabel('Buzz'), findsNothing);
expect(
find.text('Choose an address for your community.'),
findsOneWidget,
);
final input = find.byKey(const Key('community-create-input'));
final field = tester.widget<CupertinoTextField>(input);
expect(field.decoration, isNull);
expect(field.focusNode!.hasFocus, isTrue);
expect(
tester
.widget<Transform>(
find.byKey(const Key('add-community-link-rise')),
)
.transform
.getTranslation()
.y,
0,
);
await tester.enterText(input, 'not a name');
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
expect(submitted, isNull);
expect(
find.text(
'Use letters, numbers and single hyphens (up to 63 characters).',
),
findsOneWidget,
);
expect(
find.ancestor(
of: find.text(
'Use letters, numbers and single hyphens (up to 63 characters).',
),
matching: find.byWidgetPredicate(
(widget) =>
widget is Semantics && widget.properties.liveRegion == true,
),
),
findsOneWidget,
);
await tester.enterText(input, ' My-Community ');
await tester.testTextInput.receiveAction(TextInputAction.go);
await tester.pumpAndSettle();
expect(submitted, 'my-community');
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('Add a community'), findsOneWidget);
await tester.tap(find.text('Enter community link'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
await tester.pumpAndSettle();
final joinInput = tester.widget<CupertinoTextField>(
find.byKey(const Key('community-link-input')),
);
expect(joinInput.controller!.text, isEmpty);
expect(joinInput.decoration, isNull);
expect(joinInput.focusNode!.hasFocus, isTrue);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
}
debugDefaultTargetPlatformOverride = null;
});
testWidgets(
'signed-in onboarding has ordered actions and switcher Add stays separate',
(tester) async {
var found = false;
var created = false;
for (final hasCommunities in [true, false]) {
await tester.pumpWidget(
WidgetHelpers.testable(
child: PairingPage(
connectionLanding: true,
signedInOnboarding: true,
hasKnownCommunities: hasCommunities,
onBuilderlab: () => found = true,
onCreateCommunity: () => created = true,
onCommunityLink: () {},
onWelcomeBack: () {},
),
),
);
await tester.pumpAndSettle();
expect(find.bySemanticsLabel('Buzz'), findsNothing);
expect(find.text('Scan a QR code'), findsOneWidget);
expect(find.text('Enter community link'), findsOneWidget);
expect(find.text('Create a community'), findsOneWidget);
expect(
find.text('Find my communities'),
hasCommunities ? findsOneWidget : findsNothing,
);
expect(
tester.getTopLeft(find.text('Scan a QR code')).dy,
lessThan(tester.getTopLeft(find.text('Enter community link')).dy),
);
if (hasCommunities) {
await tester.tap(find.text('Find my communities'));
expect(found, isTrue);
}
await tester.tap(find.text('Create a community'));
expect(created, isTrue);
}
await tester.pumpWidget(
WidgetHelpers.testable(
child: PairingPage(
connectionLanding: true,
addingCommunity: true,
onWelcomeBack: () {},
onCommunityLink: () {},
onBuilderlab: () {},
onCreateCommunity: () {},
),
),
);
await tester.pumpAndSettle();
expect(find.text('Scan a QR code'), findsOneWidget);
expect(find.text('Enter a community link'), findsOneWidget);
expect(find.text('Find my communities'), findsNothing);
expect(find.text('Create a community'), findsNothing);
},
);
testWidgets(
'verified pairing stays on loading through transfer and import',
(tester) async {
final notifier = _ConfirmingSasPairingNotifier();
await tester.pumpWidget(
WidgetHelpers.testable(
child: const PairingPage(),
overrides: [pairingProvider.overrideWith(() => notifier)],
),
);
await tester.pump();
for (final status in [
PairingStatus.transferring,
PairingStatus.storing,
PairingStatus.success,
]) {
notifier.advance(status);
await tester.pump();
expect(
find.byKey(const Key('pairing-community-loading')),
findsOneWidget,
);
expect(find.text('Scan a QR code'), findsNothing);
expect(find.text('Use pairing code'), findsNothing);
expect(find.byType(BuzzLoadingIndicator), findsOneWidget);
}
},
);
testWidgets('renders branding and progressive pairing actions', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(child: const PairingPage()),
);
expect(find.bySemanticsLabel('Buzz'), findsOneWidget);
expect(find.text('Welcome to Buzz'), findsNothing);
expect(
find.text(
'Your people, your agents, your projects —\nall in one place.',
),
findsOneWidget,
);
expect(find.text('Scan a QR code'), findsOneWidget);
expect(find.text('Use pairing code'), findsOneWidget);
expect(find.text('Connect'), findsNothing);
expect(find.byType(TextField), findsNothing);
});
testWidgets('uses compact desktop-style onboarding actions', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(child: const PairingPage()),
);
final scanButton = tester.getSize(
find.widgetWithText(FilledButton, 'Scan a QR code'),
);
final pairingCodeButton = tester.getSize(
find.widgetWithText(TextButton, 'Use pairing code'),
);
final glassFinder = find.widgetWithText(FilledButton, 'Scan a QR code');
expect(
find.descendant(of: glassFinder, matching: find.byType(BackdropFilter)),
findsOneWidget,
);
expect(scanButton.height, greaterThanOrEqualTo(44));
expect(pairingCodeButton.height, greaterThanOrEqualTo(44));
expect(
tester
.widget<TextButton>(
find.widgetWithText(TextButton, 'Use pairing code'),
)
.style!
.backgroundColor!
.resolve({}),
Colors.transparent,
);
expect(scanButton.width, lessThan(440));
expect(pairingCodeButton.width, lessThan(440));
expect(find.byType(OutlinedButton), findsNothing);
});
testWidgets(
'docks welcome actions and keeps pairing reachable with keyboard',
(tester) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(390, 844);
tester.view.padding = const FakeViewPadding(top: 59, bottom: 34);
addTearDown(tester.view.reset);
await tester.pumpWidget(
WidgetHelpers.testable(
child: const PairingPage(),
disableAnimations: true,
),
);
final toggle = find.widgetWithText(TextButton, 'Use pairing code');
expect(tester.getBottomLeft(toggle).dy, 844 - 34 - Grid.sm);
expect(
tester
.getBottomLeft(find.byKey(const Key('pairing-buzz-wordmark')))
.dy,
lessThan(tester.getTopLeft(toggle).dy - 150),
);
final wordmark = find.byKey(const Key('pairing-buzz-wordmark'));
final originalFrame = tester.getRect(wordmark);
for (final distance in [-150.0, 150.0]) {
await tester.drag(find.byType(CustomScrollView), Offset(0, distance));
await tester.pump(const Duration(milliseconds: 100));
expect(tester.getRect(wordmark), originalFrame);
expect(tester.getBottomLeft(toggle).dy, 844 - 34 - Grid.sm);
}
await _expandPairingCode(tester);
tester.view.physicalSize = const Size(360, 560);
tester.view.viewInsets = const FakeViewPadding(bottom: 240);
await tester.pump();
await tester.enterText(find.byType(TextField), 'pairing-code-draft');
final connect = find.widgetWithText(FilledButton, 'Connect');
await tester.ensureVisible(connect);
await tester.pump();
expect(connect.hitTestable(), findsOneWidget);
expect(tester.getBottomLeft(connect).dy, lessThanOrEqualTo(560 - 240));
expect(find.text('pairing-code-draft'), findsOneWidget);
expect(tester.takeException(), isNull);
},
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
);
testWidgets('uses dark status-bar icons on the onboarding surface', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(child: const PairingPage()),
);
final overlay = tester.widget<AnnotatedRegion<SystemUiOverlayStyle>>(
find.byKey(const Key('pairing-onboarding-system-overlay')),
);
expect(overlay.value.statusBarIconBrightness, Brightness.dark);
expect(overlay.value.statusBarColor, Colors.transparent);
});
testWidgets(
'switches welcome artwork and readable controls with appearance',
(tester) async {
Future<void> show(Brightness brightness) async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(
theme: brightness == Brightness.dark
? AppTheme.dark()
: AppTheme.light(),
home: const PairingPage(),
),
),
);
await tester.pump(const Duration(milliseconds: 300));
}
await show(Brightness.light);
var decoration =
tester
.widget<DecoratedBox>(
find.byKey(const Key('pairing-onboarding-background')),
)
.decoration
as BoxDecoration;
expect(
(decoration.image!.image as AssetImage).assetName,
'assets/images/shell-gradient.png',
);
expect(decoration.image!.fit, BoxFit.fill);
await _expandPairingCode(tester);
await tester.enterText(find.byType(TextField), 'pairing-code-draft');
await show(Brightness.dark);
decoration =
tester
.widget<DecoratedBox>(
find.byKey(const Key('pairing-onboarding-background')),
)
.decoration
as BoxDecoration;
expect(decoration.image, isNull);
expect(decoration.color, const Color(0xFF11181D));
expect(
tester
.widget<OnboardingWordmark>(
find.byKey(const Key('pairing-buzz-wordmark')),
)
.color,
const Color(0xFFE6EDF0),
);
expect(
tester.widget<TextField>(find.byType(TextField)).style!.color,
const Color(0xFFE6EDF0),
);
expect(
tester
.widget<TextField>(find.byType(TextField))
.decoration!
.fillColor,
const Color(0xFF233039),
);
expect(find.text('pairing-code-draft'), findsOneWidget);
expect(
tester
.widget<AnnotatedRegion<SystemUiOverlayStyle>>(
find.byKey(const Key('pairing-onboarding-system-overlay')),
)
.value
.statusBarIconBrightness,
Brightness.light,
);
await show(Brightness.light);
expect(
tester
.widget<OnboardingWordmark>(
find.byKey(const Key('pairing-buzz-wordmark')),
)
.color,
isNull,
);
expect(find.text('pairing-code-draft'), findsOneWidget);
expect(tester.takeException(), isNull);
},
);
testWidgets('uses the onboarding surface for dark-theme SAS verification', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
pairingProvider.overrideWith(() => _ConfirmingSasPairingNotifier()),
],
child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()),
),
);
final overlay = tester.widget<AnnotatedRegion<SystemUiOverlayStyle>>(
find.byKey(const Key('pairing-sas-system-overlay')),
);
expect(overlay.value.statusBarIconBrightness, Brightness.light);
expect(overlay.value.statusBarColor, Colors.transparent);
final background = tester.widget<DecoratedBox>(
find.byKey(const Key('pairing-onboarding-background')),
);
final backgroundDecoration = background.decoration as BoxDecoration;
expect(backgroundDecoration.color, const Color(0xFF11181D));
expect(backgroundDecoration.image, isNull);
expect(
tester.widget<Scaffold>(find.byType(Scaffold)).backgroundColor,
Colors.transparent,
);
expect(find.text('Enter pairing code'), findsOneWidget);
expect(
find.text(
'Make sure the six-digit code matches on both devices. Your Buzz identity will transfer to this device. Only continue if you started this pairing from your desktop.',
),
findsNothing,
);
expect(find.text('Does your desktop app show this code?'), findsNothing);
});
testWidgets('fullscreen pairing supports Cancel and system Back', (
tester,
) async {
final notifier = _ConfirmingSasPairingNotifier();
await tester.pumpWidget(
ProviderScope(
overrides: [pairingProvider.overrideWith(() => notifier)],
child: MaterialApp(
theme: AppTheme.dark(),
home: const PairingPage(
addingCommunity: true,
connectionLanding: true,
),
),
),
);
expect(find.byType(AppBar), findsNothing);
expect(find.text('Add Community'), findsNothing);
expect(find.byIcon(BuzzIcons.arrowLeft), findsNothing);
expect(find.byKey(const Key('pairing-pop-scope')), findsOneWidget);
await tester.tap(find.widgetWithText(TextButton, 'Cancel'));
expect(notifier.denied, isTrue);
notifier.denied = false;
await tester.binding.handlePopRoute();
expect(notifier.denied, isTrue);
});
testWidgets('uses the shared frosted add-community header', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(child: const PairingPage(addingCommunity: true)),
);
expect(find.byType(AppBar), findsNothing);
expect(find.byType(FrostedAppBar), findsOneWidget);
expect(find.text('Add Community'), findsNothing);
final appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar));
expect(appBar.alwaysFrosted, isTrue);
expect(find.byTooltip('Back'), findsOneWidget);
});
testWidgets('uses the native navigation bar on iOS', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
await tester.pumpWidget(
WidgetHelpers.testable(child: const PairingPage(addingCommunity: true)),
);
final nativeBar = tester.widget<UiKitView>(find.byType(UiKitView));
expect(nativeBar.viewType, 'buzz/ios_navigation_bar');
expect(nativeBar.creationParams, containsPair('title', ''));
debugDefaultTargetPlatformOverride = null;
});
testWidgets('reveals pairing code field and connect action', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(child: const PairingPage()),
);
await _expandPairingCode(tester);
expect(find.text('Hide pairing code'), findsOneWidget);
expect(find.text('Connect'), findsOneWidget);
expect(find.byType(TextField), findsOneWidget);
});
testWidgets('connect button is below text field, not beside it', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(child: const PairingPage()),
);
await _expandPairingCode(tester);
final textField = tester.getBottomLeft(find.byType(TextField));
final connectButton = tester.getTopLeft(
find.widgetWithText(FilledButton, 'Connect'),
);
// The connect button should be below the text field.
expect(connectButton.dy, greaterThan(textField.dy));
});
testWidgets('connect button is full width', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(child: const PairingPage()),
);
await _expandPairingCode(tester);
final connectButton = tester.getSize(
find.widgetWithText(FilledButton, 'Connect'),
);
final textField = tester.getSize(find.byType(TextField));
// Button width should be close to the text field width (both full-width).
expect(connectButton.width, closeTo(textField.width, 2.0));
});
testWidgets('shows error container when pairing fails', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
pairingProvider.overrideWith(
() => _ErrorPairingNotifier('Invalid pairing code: bad input'),
),
],
child: const PairingPage(),
),
);
await tester.pump();
expect(find.text('Invalid pairing code: bad input'), findsOneWidget);
});
testWidgets('shows spinner when connecting', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
pairingProvider.overrideWith(() => _ConnectingPairingNotifier()),
],
child: const PairingPage(),
),
);
await tester.pump();
expect(find.byType(BuzzLoadingIndicator), findsOneWidget);
// Connect text should be replaced by spinner.
expect(find.text('Connect'), findsNothing);
});
testWidgets('connection progress replaces pairing actions', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
pairingProvider.overrideWith(() => _ConnectingPairingNotifier()),
],
child: const PairingPage(),
),
);
await tester.pump();
expect(
find.byKey(const Key('pairing-community-loading')),
findsOneWidget,
);
expect(find.text('Scan a QR code'), findsNothing);
expect(find.text('Use pairing code'), findsNothing);
});
testWidgets('connection Cancel closes the identity recovery route', (
tester,
) async {
final navigator = GlobalKey<NavigatorState>();
await tester.pumpWidget(
ProviderScope(
overrides: [
pairingProvider.overrideWith(() => _ConnectingPairingNotifier()),
],
child: MaterialApp(
navigatorKey: navigator,
theme: AppTheme.light(),
home: const Scaffold(body: Text('Settings')),
),
),
);
navigator.currentState!.push(
MaterialPageRoute<void>(
builder: (_) => const PairingPage(
addingCommunity: true,
identityRecoveryOnly: true,
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
await tester.tap(find.widgetWithText(TextButton, 'Cancel'));
await tester.pumpAndSettle();
expect(find.byType(PairingPage), findsNothing);
expect(find.text('Settings'), findsOneWidget);
});
testWidgets('recovery entry rejects ordinary nostrpair codes', (
tester,
) async {
final notifier = _RecordingPairingNotifier();
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [pairingProvider.overrideWith(() => notifier)],
child: const PairingPage(
addingCommunity: true,
identityRecoveryOnly: true,
),
),
);
await _expandPairingCode(tester);
await tester.enterText(find.byType(TextField), 'nostrpair://ordinary');
await tester.ensureVisible(find.text('Connect'));
await tester.pump();
await tester.tap(find.text('Connect'));
await tester.pump();
expect(find.text('Scan a desktop recovery code.'), findsOneWidget);
expect(notifier.pairedCodes, isEmpty);
});
testWidgets('recovery entry accepts mode=recover codes', (tester) async {
final notifier = _RecordingPairingNotifier();
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [pairingProvider.overrideWith(() => notifier)],
child: const PairingPage(
addingCommunity: true,
identityRecoveryOnly: true,
),
),
);
await _expandPairingCode(tester);
const code = 'nostrpair://desktop?mode=recover';
await tester.enterText(find.byType(TextField), code);
await tester.ensureVisible(find.text('Connect'));
await tester.pump();
await tester.tap(find.text('Connect'));
await tester.pump();
expect(notifier.pairedCodes, [code]);
});
testWidgets('new identity import offers protection after code entry', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
pairingProvider.overrideWith(() => _ConfirmingSasPairingNotifier()),
],
child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()),
),
);
expect(find.text('Use biometrics'), findsNothing);
await tester.enterText(
find.byKey(const Key('pairing-code-input')),
'123456',
);
await tester.pump();
expect(find.text('Protect your identity'), findsOneWidget);
expect(find.text('Use biometrics'), findsOneWidget);
expect(find.text('Skip'), findsOneWidget);
expect(find.byType(Checkbox), findsNothing);
});
testWidgets('uses the native Face ID label on iOS', (tester) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
try {
await tester.pumpWidget(
ProviderScope(
overrides: [
pairingProvider.overrideWith(
() => _ConfirmingSasPairingNotifier(),
),
enrolledBiometricsProvider.overrideWith(
(_) async => const [BiometricType.face],
),
],
child: MaterialApp(
theme: AppTheme.dark(),
home: const PairingPage(),
),
),
);
await tester.pump();
await tester.enterText(
find.byKey(const Key('pairing-code-input')),
'123456',
);
await tester.pump();
expect(find.text('Use Face ID'), findsOneWidget);
expect(find.text('Use biometrics'), findsNothing);
} finally {
debugDefaultTargetPlatformOverride = previousPlatform;
}
});
testWidgets('uses the native Touch ID label on iOS', (tester) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
try {
await tester.pumpWidget(
ProviderScope(
overrides: [
pairingProvider.overrideWith(
() => _ConfirmingSasPairingNotifier(),
),
enrolledBiometricsProvider.overrideWith(
(_) async => const [BiometricType.fingerprint],
),
],
child: MaterialApp(
theme: AppTheme.dark(),
home: const PairingPage(),
),
),
);
await tester.pump();
await tester.enterText(
find.byKey(const Key('pairing-code-input')),
'123456',
);
await tester.pump();
expect(find.text('Use Touch ID'), findsOneWidget);
expect(find.text('Use Face ID'), findsNothing);
} finally {
debugDefaultTargetPlatformOverride = previousPlatform;
}
});
testWidgets('desktop recovery does not show protection checkbox', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
pairingProvider.overrideWith(
() => _ConfirmingSasPairingNotifier(sendsIdentityToDesktop: true),
),
],
child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()),
),
);
expect(
find.byKey(const Key('protect-sensitive-actions-checkbox')),
findsNothing,
);
});
testWidgets('recovery SAS shows the code without explanatory subcopy', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
pairingProvider.overrideWith(
() => _ConfirmingSasPairingNotifier(sendsIdentityToDesktop: true),
),
],
child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()),
),
);
expect(find.textContaining('full Buzz identity'), findsNothing);
expect(find.textContaining('permanent access'), findsNothing);
expect(find.textContaining('started this recovery'), findsNothing);
expect(find.text('Continue'), findsNothing);
});
testWidgets('matches the onboarding visual system and SAS action layout', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
pairingProvider.overrideWith(() => _ConfirmingSasPairingNotifier()),
],
child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()),
),
);
expect(find.byIcon(BuzzIcons.shieldCheck), findsNothing);
expect(find.text('Enter pairing code'), findsOneWidget);
expect(
find.text(
'Make sure the six-digit code matches on both devices. Your Buzz identity will transfer to this device. Only continue if you started this pairing from your desktop.',
),
findsNothing,
);
expect(find.text('Does your desktop app show this code?'), findsNothing);
final codeFinder = find.byKey(const Key('pairing-code-input'));
expect(codeFinder, findsOneWidget);
expect(find.text('123 456'), findsNothing);
const onboardingInk = Color(0xFFE6EDF0);
const onboardingCtaLabel = Color(0xFF172229);
await tester.enterText(codeFinder, '123456');
await tester.pump();
final confirmFinder = find.widgetWithText(FilledButton, 'Use biometrics');
final cancelFinder = find.widgetWithText(TextButton, 'Skip');
final confirmButton = tester.widget<FilledButton>(confirmFinder);
final cancelButton = tester.widget<TextButton>(cancelFinder);
expect(
confirmButton.style?.backgroundColor?.resolve(<WidgetState>{}),
onboardingInk,
);
expect(
confirmButton.style?.foregroundColor?.resolve(<WidgetState>{}),
onboardingCtaLabel,
);
expect(
confirmButton.style?.shape?.resolve(<WidgetState>{}),
isA<StadiumBorder>(),
);
expect(
cancelButton.style?.backgroundColor?.resolve(<WidgetState>{}),
onboardingInk.withValues(alpha: 0.1),
);
expect(
cancelButton.style?.foregroundColor?.resolve(<WidgetState>{}),
onboardingInk,
);
expect(
cancelButton.style?.shape?.resolve(<WidgetState>{}),
isA<StadiumBorder>(),
);
final confirmTopLeft = tester.getTopLeft(confirmFinder);
final cancelTopLeft = tester.getTopLeft(cancelFinder);
final scaffoldWidth = tester.getSize(find.byType(Scaffold)).width;
expect(confirmTopLeft.dy, lessThan(cancelTopLeft.dy));
expect(confirmTopLeft.dx, cancelTopLeft.dx);
expect(confirmTopLeft.dx, Grid.sm);
expect(tester.getSize(confirmFinder).width, scaffoldWidth - Grid.sm * 2);
expect(tester.getSize(cancelFinder).width, scaffoldWidth - Grid.sm * 2);
expect(tester.getSize(confirmFinder).height, 52);
expect(tester.getSize(cancelFinder).height, 52);
expect(
find.textContaining(
'Only continue if you started this pairing from your desktop.',
),
findsNothing,
);
expect(
tester.getBottomLeft(find.byType(Scaffold)).dy -
tester.getBottomLeft(cancelFinder).dy,
Grid.sm,
);
});
for (final brightness in Brightness.values) {
testWidgets('uses accessible SAS error contrast in ${brightness.name}', (
tester,
) async {
const errorMessage =
'Identity confirmation failed. Nothing transferred.';
final isDark = brightness == Brightness.dark;
await tester.pumpWidget(
ProviderScope(
overrides: [
pairingProvider.overrideWith(
() => _ConfirmingSasPairingNotifier(errorMessage: errorMessage),
),
],
child: MaterialApp(
theme: isDark ? AppTheme.dark() : AppTheme.light(),
home: const PairingPage(),
),
),
);
await tester.pumpAndSettle();
final errorFinder = find.text(errorMessage);
expect(Theme.of(tester.element(errorFinder)).brightness, brightness);
final errorInk = tester.widget<Text>(errorFinder).style!.color!;
expect(
errorInk,
isDark ? const Color(0xFFE6EDF0) : const Color(0xFF111111),
);
final backgroundFinder = find.byKey(
const Key('pairing-onboarding-background'),
);
final background = tester.widget<DecoratedBox>(backgroundFinder);
final decoration = background.decoration as BoxDecoration;
expect(
decoration.color,
isDark ? const Color(0xFF11181D) : const Color(0xFFE7F0EF),
);
if (isDark) {
expect(decoration.image, isNull);
} else {
expect(
(decoration.image!.image as AssetImage).assetName,
'assets/images/shell-gradient.png',
);
await tester.runAsync(
() => precacheImage(
decoration.image!.image,
tester.element(backgroundFinder),
),
);
}
final size = tester.getSize(backgroundFinder);
final errorRect = tester
.getRect(errorFinder)
.shift(-tester.getTopLeft(backgroundFinder));
// Render the production decoration and painter without foreground
// content, so sampled pixels are the actual surface behind the error.
const captureKey = Key('sas-error-background-capture');
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: RepaintBoundary(
key: captureKey,
child: SizedBox.fromSize(
size: size,
child: DecoratedBox(
decoration: decoration,
child: CustomPaint(
painter: (background.child! as CustomPaint).painter,
),
),
),
),
),
);
await tester.pumpAndSettle();
final boundary = tester.renderObject<RenderRepaintBoundary>(
find.byKey(captureKey),
);
await tester.runAsync(() async {
final image = await boundary.toImage(pixelRatio: 1);
try {
final pixels = (await image.toByteData(
format: ui.ImageByteFormat.rawRgba,
))!;
for (var y = errorRect.top.ceil(); y < errorRect.bottom; y += 4) {
for (var x = errorRect.left.ceil(); x < errorRect.right; x += 4) {
final offset = (y * image.width + x) * 4;
expect(pixels.getUint8(offset + 3), 255);
final surface = Color.fromARGB(
255,
pixels.getUint8(offset),
pixels.getUint8(offset + 1),
pixels.getUint8(offset + 2),
);
expect(
_contrastRatio(errorInk, surface),
greaterThanOrEqualTo(4.5),
reason: '${brightness.name} error contrast at ($x, $y)',
);
}
}
} finally {
image.dispose();
}
});
expect(tester.takeException(), isNull);
});
}
testWidgets('keeps SAS actions above the keyboard on small screens', (
tester,
) async {
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(360, 560);
tester.view.viewInsets = const FakeViewPadding(bottom: 200);
addTearDown(tester.view.reset);
await tester.pumpWidget(
ProviderScope(
overrides: [
pairingProvider.overrideWith(() => _ConfirmingSasPairingNotifier()),
],
child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()),
),
);
expect(tester.takeException(), isNull);
expect(find.byType(SingleChildScrollView), findsOneWidget);
final cancelFinder = find.widgetWithText(TextButton, 'Cancel');
expect(tester.getBottomLeft(cancelFinder).dy, 560 - 200 - Grid.sm);
await tester.drag(
find.byType(SingleChildScrollView),
const Offset(0, -100),
);
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.text('Enter pairing code'), findsOneWidget);
expect(find.textContaining('matches on both devices'), findsNothing);
expect(find.textContaining('Buzz identity will transfer'), findsNothing);
expect(find.text('Continue'), findsNothing);
});
});
}
double _contrastRatio(Color foreground, Color background) {
final foregroundLuminance = foreground.computeLuminance();
final backgroundLuminance = background.computeLuminance();
final lighter = foregroundLuminance > backgroundLuminance
? foregroundLuminance
: backgroundLuminance;
final darker = foregroundLuminance > backgroundLuminance
? backgroundLuminance
: foregroundLuminance;
return (lighter + 0.05) / (darker + 0.05);
}
Future<void> _expandPairingCode(WidgetTester tester) async {
await tester.ensureVisible(find.text('Use pairing code'));
await tester.pump();
await tester.tap(find.text('Use pairing code'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
}
class _ErrorPairingNotifier extends Notifier<PairingState>
implements PairingNotifier {
@override
Future<bool> verifyDesktopCode(String code) async => false;
final String error;
_ErrorPairingNotifier(this.error);
@override
PairingState build() =>
PairingState(status: PairingStatus.error, errorMessage: error);
@override
Future<bool> authorizeIdentityExport({required Community community}) async =>
true;
@override
Future<void> pair(String rawInput) async {}
@override
void reset() {}
@override
void confirmSas() {}
@override
void setProtectSensitiveActions(bool value) {}
@override
void denySas() {}
}
class _ConnectingPairingNotifier extends Notifier<PairingState>
implements PairingNotifier {
@override
Future<bool> verifyDesktopCode(String code) async => false;
@override
PairingState build() => const PairingState(status: PairingStatus.connecting);
@override
Future<bool> authorizeIdentityExport({required Community community}) async =>
true;
@override
Future<void> pair(String rawInput) async {}
@override
void reset() {}
@override
void confirmSas() {}
@override
void setProtectSensitiveActions(bool value) {}
@override
void denySas() {}
}
class _RecordingPairingNotifier extends Notifier<PairingState>
implements PairingNotifier {
@override
Future<bool> verifyDesktopCode(String code) async => false;
final pairedCodes = <String>[];
@override
PairingState build() => const PairingState();
@override
Future<bool> authorizeIdentityExport({required Community community}) async =>
true;
@override
Future<void> pair(String rawInput) async => pairedCodes.add(rawInput);
@override
void reset() {}
@override
void confirmSas() {}
@override
void setProtectSensitiveActions(bool value) {}
@override
void denySas() {}
}
class _ConfirmingSasPairingNotifier extends Notifier<PairingState>
implements PairingNotifier {
@override
Future<bool> verifyDesktopCode(String code) async => false;
_ConfirmingSasPairingNotifier({
this.sendsIdentityToDesktop = false,
this.errorMessage,
});
final bool sendsIdentityToDesktop;
final String? errorMessage;
bool denied = false;
void advance(PairingStatus status) => state = state.copyWith(status: status);
@override
PairingState build() => PairingState(
status: PairingStatus.confirmingSas,
sasCode: '123456',
sendsIdentityToDesktop: sendsIdentityToDesktop,
errorMessage: errorMessage,
);
@override
Future<bool> authorizeIdentityExport({required Community community}) async =>
true;
@override
Future<void> pair(String rawInput) async {}
@override
void reset() {}
@override
void confirmSas() {}
@override
void setProtectSensitiveActions(bool value) {
state = state.copyWith(protectSensitiveActions: value);
}
@override
void denySas() => denied = true;
}