Compare commits
3
Commits
bd675357bb
...
30f06c2f8f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30f06c2f8f | ||
|
|
c6ab4680fd | ||
|
|
86b6aafe7d |
No files matched your search
@@ -149,3 +149,68 @@ text uses a normal text node to remain exposed to assistive technology.
|
||||
Delivered in [PR #13](https://git.sendico.io/tech/sxchnge/pulls/13), branch
|
||||
`onboarding-12`. The final browser accessibility tree exposes the recipient text,
|
||||
demo status, status heading and recovery actions. PR is open for review.
|
||||
|
||||
## Browser credential autofill
|
||||
|
||||
Task: [#56](https://git.sendico.io/tech/sxchnge/issues/56), branch `auth-56`.
|
||||
Delivery: [PR #58](https://git.sendico.io/tech/sxchnge/pulls/58), implementation
|
||||
commit `86b6aaf`. Open for review; no merge or deployment is included.
|
||||
The consumer auth form groups its mounted inputs in one `AutofillGroup` and
|
||||
declares their purpose explicitly:
|
||||
|
||||
| Input | Flutter hints | Browser autocomplete |
|
||||
| --- | --- | --- |
|
||||
| Signup name | `name` with the name keyboard | `name` |
|
||||
| Account email | `username`, then `email`, with the email keyboard | `username` |
|
||||
| Login password | `password` | `current-password` |
|
||||
| Signup password and confirmation | `newPassword` on both | `new-password` |
|
||||
|
||||
Flutter uses the first hint on web and Apple platforms. Email is the credential
|
||||
username; the email keyboard remains configured separately. Visibility toggles
|
||||
keep the password role, so autofill does not depend on detecting masked input.
|
||||
The confirmation field participates in the same group as the first password,
|
||||
allowing the browser to fill both without copying one controller into the other.
|
||||
|
||||
Signup acceptance finalizes autofill before clearing password controllers and
|
||||
navigating to email verification. Login finalizes on success; when a second
|
||||
factor is required, it waits for verification and login completion. The password
|
||||
remains only in the existing form controller during that challenge, then clears
|
||||
on completed navigation or cancellation. Invalid input, rejected credentials and
|
||||
failed verification do not request saving. Switching auth mode, abandoning the
|
||||
form or leaving for unverified-account recovery cancels the autofill context.
|
||||
Provider/service/model boundaries and existing request/outcome telemetry are
|
||||
unchanged; no field events or credential telemetry are added. Browser popup
|
||||
display and pre-submit autofill abandonment remain outside current measurement.
|
||||
|
||||
### Verification and release check
|
||||
|
||||
Focused widget tests send a platform autofill response with all four signup
|
||||
values and check EN/RU controller updates, grouped credential metadata, visibility
|
||||
changes, mode switching and abandonment. Auth tests cover validation failures,
|
||||
service rejection/retry, stale responses, signup/login acceptance, unverified
|
||||
recovery, invalid second-factor codes and cancellation without premature saving.
|
||||
Existing narrow/desktop layouts and enlarged-text tests cover the unchanged UI.
|
||||
|
||||
Native Safari verification remains a release check: computer-control permission
|
||||
was unavailable in this session. On the deployed HTTPS origin with Safari
|
||||
AutoFill enabled, focus the signup email and password fields, choose a suggested
|
||||
strong password, verify that both password fields populate, then confirm that
|
||||
login offers the saved credential. Repeat after showing/hiding passwords and
|
||||
switching modes. Browser settings, stored credentials and browser heuristics
|
||||
determine whether suggestions appear; automated Flutter tests cannot establish
|
||||
that the system popup was displayed or that credentials were saved.
|
||||
|
||||
References: [Apple Password AutoFill](https://developer.apple.com/documentation/security/enabling-password-autofill-on-an-html-input-element),
|
||||
[Flutter autofill hints](https://api.flutter.dev/flutter/material/TextField/autofillHints.html),
|
||||
and [AutofillGroup](https://api.flutter.dev/flutter/widgets/AutofillGroup-class.html).
|
||||
|
||||
Recorded validation: `dart format --output=none --set-exit-if-changed` passes for
|
||||
all five changed Dart files; `flutter analyze` passes in `frontend/app`;
|
||||
`make test-frontend` passes all 212 tests across the app and shared packages.
|
||||
`flutter build web` passes. The built page's default rendering path was inspected
|
||||
in the in-app browser: login exposes `username`/`current-password` in one HTML
|
||||
form; signup exposes `username`, `name` and two `new-password` inputs in one form.
|
||||
This verifies the browser metadata, not Safari's credential popup. With Flutter
|
||||
web accessibility semantics enabled, unfocused semantic inputs initially use
|
||||
framework-default autocomplete values until focused; password-manager behavior
|
||||
with assistive technology still requires a native browser audit.
|
||||
+146
-117
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
@@ -63,6 +64,8 @@ class _AuthFormState extends State<AuthForm> {
|
||||
|
||||
void _switch(bool signUp) {
|
||||
if (signUp == _signUp) return;
|
||||
FocusScope.of(context).unfocus();
|
||||
TextInput.finishAutofillContext(shouldSave: false);
|
||||
context.read<JourneyRecorder>().abandon(JourneyExit.modeChange);
|
||||
context.read<LoginProvider>().reset();
|
||||
context.read<SignupProvider>().reset();
|
||||
@@ -95,6 +98,7 @@ class _AuthFormState extends State<AuthForm> {
|
||||
return;
|
||||
}
|
||||
final email = _email.text.trim();
|
||||
TextInput.finishAutofillContext();
|
||||
_password.clear();
|
||||
_confirmation.clear();
|
||||
context.goNamed(AppRoutes.verificationSent, extra: email);
|
||||
@@ -107,13 +111,13 @@ class _AuthFormState extends State<AuthForm> {
|
||||
);
|
||||
if (!mounted || _signUp) return;
|
||||
if (login.state.error == AuthError.accountNotVerified) {
|
||||
TextInput.finishAutofillContext(shouldSave: false);
|
||||
_password.clear();
|
||||
context.goNamed(AppRoutes.verificationSent, extra: _email.text.trim());
|
||||
return;
|
||||
}
|
||||
final challenge = login.state.result?.challenge;
|
||||
if (challenge != null) {
|
||||
_password.clear();
|
||||
final grant = await verifyFactor(
|
||||
context,
|
||||
challenge: challenge,
|
||||
@@ -123,6 +127,8 @@ class _AuthFormState extends State<AuthForm> {
|
||||
);
|
||||
if (!mounted || _signUp) return;
|
||||
if (grant == null) {
|
||||
TextInput.finishAutofillContext(shouldSave: false);
|
||||
_password.clear();
|
||||
login.reset();
|
||||
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
|
||||
return;
|
||||
@@ -137,9 +143,13 @@ class _AuthFormState extends State<AuthForm> {
|
||||
!_signUp &&
|
||||
identical(login.state.result, accepted) &&
|
||||
login.state.status == AuthStatus.success) {
|
||||
TextInput.finishAutofillContext();
|
||||
_password.clear();
|
||||
context.go(offer ? '/identity?onboarding=1' : '/accounts');
|
||||
}
|
||||
}
|
||||
} else if (login.state.status == AuthStatus.success) {
|
||||
TextInput.finishAutofillContext();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,150 +205,169 @@ class _AuthFormState extends State<AuthForm> {
|
||||
expandedInsets: EdgeInsets.zero,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Form(
|
||||
key: _form,
|
||||
autovalidateMode: _submitted
|
||||
? AutovalidateMode.onUserInteraction
|
||||
: AutovalidateMode.disabled,
|
||||
onChanged: () {
|
||||
if (_provider.state.status == AuthStatus.success ||
|
||||
_provider.state.status == AuthStatus.error) {
|
||||
_provider.reset();
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_signUp) ...[
|
||||
AutofillGroup(
|
||||
// Leaving an unfinished form must not offer to save its credentials.
|
||||
onDisposeAction: AutofillContextAction.cancel,
|
||||
child: Form(
|
||||
key: _form,
|
||||
autovalidateMode: _submitted
|
||||
? AutovalidateMode.onUserInteraction
|
||||
: AutovalidateMode.disabled,
|
||||
onChanged: () {
|
||||
if (_provider.state.status == AuthStatus.success ||
|
||||
_provider.state.status == AuthStatus.error) {
|
||||
_provider.reset();
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (_signUp) ...[
|
||||
TextFormField(
|
||||
key: const ValueKey('name'),
|
||||
controller: _name,
|
||||
enabled: !state.isLoading,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.name,
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
errorMaxLines: 3,
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
keyboardType: TextInputType.name,
|
||||
autofillHints: const [AutofillHints.name],
|
||||
textCapitalization: TextCapitalization.words,
|
||||
validator: (value) => AuthValidation.name(value, l),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
TextFormField(
|
||||
key: const ValueKey('name'),
|
||||
controller: _name,
|
||||
key: const ValueKey('email'),
|
||||
controller: _email,
|
||||
enabled: !state.isLoading,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.name,
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
labelText: l.email,
|
||||
hintText: l.emailHint,
|
||||
prefixIcon: Icon(Icons.alternate_email),
|
||||
errorMaxLines: 3,
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autofillHints: const [
|
||||
AutofillHints.username,
|
||||
AutofillHints.email,
|
||||
],
|
||||
textInputAction: TextInputAction.next,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
validator: (value) => AuthValidation.name(value, l),
|
||||
autocorrect: false,
|
||||
validator: (value) => AuthValidation.email(value, l),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
TextFormField(
|
||||
key: const ValueKey('email'),
|
||||
controller: _email,
|
||||
enabled: !state.isLoading,
|
||||
decoration: InputDecoration(
|
||||
labelText: l.email,
|
||||
hintText: l.emailHint,
|
||||
prefixIcon: Icon(Icons.alternate_email),
|
||||
errorMaxLines: 3,
|
||||
),
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
textInputAction: TextInputAction.next,
|
||||
autocorrect: false,
|
||||
validator: (value) => AuthValidation.email(value, l),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
key: const ValueKey('password'),
|
||||
enabled: !state.isLoading,
|
||||
controller: _password,
|
||||
obscureText: !_showPassword,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
textInputAction: _signUp
|
||||
? TextInputAction.next
|
||||
: TextInputAction.done,
|
||||
onFieldSubmitted: (_) {
|
||||
if (!_signUp) _submit();
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: l.password,
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
errorMaxLines: 3,
|
||||
helperText: _signUp ? l.passwordHint : null,
|
||||
helperMaxLines: 3,
|
||||
suffixIcon: IconButton(
|
||||
tooltip: _showPassword ? l.hidePassword : l.showPassword,
|
||||
onPressed: () =>
|
||||
setState(() => _showPassword = !_showPassword),
|
||||
icon: Icon(
|
||||
_showPassword
|
||||
? Icons.visibility_off_outlined
|
||||
: Icons.visibility_outlined,
|
||||
),
|
||||
),
|
||||
),
|
||||
validator: (value) =>
|
||||
AuthValidation.password(value, l, signUp: _signUp),
|
||||
),
|
||||
if (_signUp) ...[
|
||||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
key: const ValueKey('confirmation'),
|
||||
key: const ValueKey('password'),
|
||||
enabled: !state.isLoading,
|
||||
controller: _confirmation,
|
||||
obscureText: !_showConfirmation,
|
||||
controller: _password,
|
||||
obscureText: !_showPassword,
|
||||
autofillHints: [
|
||||
_signUp
|
||||
? AutofillHints.newPassword
|
||||
: AutofillHints.password,
|
||||
],
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
textInputAction: _signUp
|
||||
? TextInputAction.next
|
||||
: TextInputAction.done,
|
||||
onFieldSubmitted: (_) {
|
||||
if (!_signUp) _submit();
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
labelText: l.confirmPassword,
|
||||
labelText: l.password,
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
errorMaxLines: 3,
|
||||
helperText: _signUp ? l.passwordHint : null,
|
||||
helperMaxLines: 3,
|
||||
suffixIcon: IconButton(
|
||||
tooltip: _showConfirmation
|
||||
? l.hideConfirmation
|
||||
: l.showConfirmation,
|
||||
onPressed: () => setState(
|
||||
() => _showConfirmation = !_showConfirmation,
|
||||
),
|
||||
tooltip: _showPassword ? l.hidePassword : l.showPassword,
|
||||
onPressed: () =>
|
||||
setState(() => _showPassword = !_showPassword),
|
||||
icon: Icon(
|
||||
_showConfirmation
|
||||
_showPassword
|
||||
? Icons.visibility_off_outlined
|
||||
: Icons.visibility_outlined,
|
||||
),
|
||||
),
|
||||
),
|
||||
validator: (value) =>
|
||||
AuthValidation.confirmation(value, _password.text, l),
|
||||
AuthValidation.password(value, l, signUp: _signUp),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 28),
|
||||
FilledButton(
|
||||
key: const ValueKey('submit'),
|
||||
onPressed: state.isLoading || state.status == AuthStatus.success
|
||||
? null
|
||||
: _submit,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
state.isLoading
|
||||
? l.authWorking
|
||||
: (_signUp ? l.createAccount : l.letsGo),
|
||||
textAlign: TextAlign.center,
|
||||
if (_signUp) ...[
|
||||
const SizedBox(height: 20),
|
||||
TextFormField(
|
||||
key: const ValueKey('confirmation'),
|
||||
enabled: !state.isLoading,
|
||||
controller: _confirmation,
|
||||
obscureText: !_showConfirmation,
|
||||
autofillHints: const [AutofillHints.newPassword],
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
decoration: InputDecoration(
|
||||
labelText: l.confirmPassword,
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
errorMaxLines: 3,
|
||||
suffixIcon: IconButton(
|
||||
tooltip: _showConfirmation
|
||||
? l.hideConfirmation
|
||||
: l.showConfirmation,
|
||||
onPressed: () => setState(
|
||||
() => _showConfirmation = !_showConfirmation,
|
||||
),
|
||||
icon: Icon(
|
||||
_showConfirmation
|
||||
? Icons.visibility_off_outlined
|
||||
: Icons.visibility_outlined,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
state.isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.arrow_forward, size: 20),
|
||||
],
|
||||
),
|
||||
validator: (value) =>
|
||||
AuthValidation.confirmation(value, _password.text, l),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 28),
|
||||
FilledButton(
|
||||
key: const ValueKey('submit'),
|
||||
onPressed:
|
||||
state.isLoading || state.status == AuthStatus.success
|
||||
? null
|
||||
: _submit,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
state.isLoading
|
||||
? l.authWorking
|
||||
: (_signUp ? l.createAccount : l.letsGo),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
state.isLoading
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.arrow_forward, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.status == AuthStatus.success ||
|
||||
|
||||
@@ -92,6 +92,14 @@ void main() {
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
|
||||
expect(
|
||||
tester.testTextInput.log,
|
||||
isNot(
|
||||
contains(
|
||||
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<TextFormField>(find.byKey(const ValueKey('email')))
|
||||
@@ -153,6 +161,14 @@ void main() {
|
||||
'/verification-sent',
|
||||
);
|
||||
expect(backend.sends, 1);
|
||||
expect(
|
||||
tester.testTextInput.log,
|
||||
isNot(
|
||||
contains(
|
||||
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
find.textContaining('a verification email is on its way'),
|
||||
findsOneWidget,
|
||||
@@ -198,6 +214,14 @@ void main() {
|
||||
find.text('Сервис недоступен. Попробуйте ещё раз.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester.testTextInput.log,
|
||||
isNot(
|
||||
contains(
|
||||
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(button);
|
||||
await tester.pump();
|
||||
expect(service.calls, hasLength(2));
|
||||
@@ -207,6 +231,12 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('auth-error')), findsNothing);
|
||||
expect(
|
||||
tester.testTextInput.log,
|
||||
contains(
|
||||
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||
),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
@@ -237,6 +267,14 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('confirmation')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
||||
expect(
|
||||
tester.testTextInput.log,
|
||||
isNot(
|
||||
contains(
|
||||
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -55,6 +55,12 @@ void main() {
|
||||
await submit(tester);
|
||||
expect(find.text('Enter your email address.'), findsOneWidget);
|
||||
expect(find.text('Enter your password.'), findsOneWidget);
|
||||
expect(
|
||||
tester.testTextInput.log.where(
|
||||
(call) => call.method == 'TextInput.finishAutofillContext',
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('email')),
|
||||
'test@example.com',
|
||||
@@ -79,6 +85,12 @@ void main() {
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
|
||||
expect(
|
||||
tester.testTextInput.log,
|
||||
contains(
|
||||
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||
),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
@@ -104,12 +116,26 @@ void main() {
|
||||
);
|
||||
await submit(tester);
|
||||
expect(find.text('Passwords do not match.'), findsOneWidget);
|
||||
expect(
|
||||
tester.testTextInput.log,
|
||||
isNot(
|
||||
contains(
|
||||
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('confirmation')),
|
||||
'example-password',
|
||||
);
|
||||
await submit(tester);
|
||||
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||
expect(
|
||||
tester.testTextInput.log,
|
||||
contains(
|
||||
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||
),
|
||||
);
|
||||
await tester.ensureVisible(
|
||||
find.byKey(const ValueKey('demo-confirmation')),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sxchnge_app/main.dart';
|
||||
import 'package:sxchnge_app/providers/locale.dart';
|
||||
|
||||
Finder field(String key) => find.byKey(ValueKey(key));
|
||||
|
||||
EditableTextState editor(WidgetTester tester, String key) =>
|
||||
tester.state<EditableTextState>(
|
||||
find.descendant(of: field(key), matching: find.byType(EditableText)),
|
||||
);
|
||||
|
||||
void main() {
|
||||
for (final language in ['en', 'ru']) {
|
||||
testWidgets(
|
||||
'platform autofill fills the signup credential group ($language)',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(1440, 1200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
SxchngeApp(
|
||||
localeProvider: LocaleProvider(initialLocale: Locale(language)),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.text(language == 'ru' ? 'Регистрация' : 'Sign up'),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.showKeyboard(field('email'));
|
||||
|
||||
final configuration = tester.testTextInput.setClientArgs!;
|
||||
expect(
|
||||
configuration['inputType']['name'],
|
||||
'TextInputType.emailAddress',
|
||||
);
|
||||
final fields = (configuration['fields'] as List).cast<Map>();
|
||||
expect(fields, hasLength(4));
|
||||
expect(
|
||||
fields.map((field) => field['autofill']['hints'].first),
|
||||
containsAll(['name', 'username', 'newPassword', 'newPassword']),
|
||||
);
|
||||
|
||||
const values = {
|
||||
'name': 'Alex Example',
|
||||
'email': 'alex@example.com',
|
||||
'password': 'generated-example-password',
|
||||
'confirmation': 'generated-example-password',
|
||||
};
|
||||
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
|
||||
SystemChannels.textInput.name,
|
||||
SystemChannels.textInput.codec.encodeMethodCall(
|
||||
MethodCall('TextInputClient.updateEditingStateWithTag', [
|
||||
0,
|
||||
{
|
||||
for (final entry in values.entries)
|
||||
editor(tester, entry.key).autofillId: TextEditingValue(
|
||||
text: entry.value,
|
||||
).toJSON(),
|
||||
},
|
||||
]),
|
||||
),
|
||||
(_) {},
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
for (final entry in values.entries) {
|
||||
expect(
|
||||
editor(tester, entry.key).currentTextEditingValue.text,
|
||||
entry.value,
|
||||
);
|
||||
}
|
||||
|
||||
// A visibility change must retain both the supplied value and its role.
|
||||
await tester.tap(
|
||||
find.byTooltip(
|
||||
language == 'ru' ? 'Показать пароль' : 'Show password',
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(editor(tester, 'password').widget.obscureText, isFalse);
|
||||
expect(editor(tester, 'password').widget.autofillHints, [
|
||||
'newPassword',
|
||||
]);
|
||||
expect(
|
||||
editor(tester, 'password').currentTextEditingValue.text,
|
||||
values['password'],
|
||||
);
|
||||
|
||||
await tester.tap(find.text(language == 'ru' ? 'Войти' : 'Log in'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.showKeyboard(field('email'));
|
||||
final loginFields =
|
||||
(tester.testTextInput.setClientArgs!['fields'] as List).cast<Map>();
|
||||
expect(loginFields, hasLength(2));
|
||||
expect(loginFields.map((field) => field['autofill']['hints'].first), [
|
||||
'username',
|
||||
'password',
|
||||
]);
|
||||
expect(
|
||||
editor(tester, 'password').currentTextEditingValue.text,
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
tester.testTextInput.log,
|
||||
contains(
|
||||
isMethodCall('TextInput.finishAutofillContext', arguments: false),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
tester.testTextInput.log,
|
||||
isNot(
|
||||
contains(
|
||||
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('leaving an unfinished auth form cancels saving', (tester) async {
|
||||
await tester.pumpWidget(const SxchngeApp());
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(field('email'), 'alex@example.com');
|
||||
await tester.enterText(field('password'), 'unfinished-password');
|
||||
tester.testTextInput.log.clear();
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.testTextInput.log,
|
||||
contains(
|
||||
isMethodCall('TextInput.finishAutofillContext', arguments: false),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
tester.testTextInput.log,
|
||||
isNot(
|
||||
contains(
|
||||
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -148,13 +148,38 @@ void main() {
|
||||
await signIn(t);
|
||||
expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
||||
expect(
|
||||
t.testTextInput.log,
|
||||
isNot(
|
||||
contains(
|
||||
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
analytics.events.where((e) => e.kind == JourneyKind.login && e.terminal),
|
||||
isEmpty,
|
||||
);
|
||||
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '000000');
|
||||
await tapFactor(t, 'two-factor-confirm');
|
||||
expect(find.byKey(const ValueKey('two-factor-error')), findsOneWidget);
|
||||
expect(
|
||||
t.testTextInput.log,
|
||||
isNot(
|
||||
contains(
|
||||
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456');
|
||||
await tapFactor(t, 'two-factor-confirm');
|
||||
expect(find.byKey(const ValueKey('account-eur')), findsOneWidget);
|
||||
expect(
|
||||
t.testTextInput.log,
|
||||
contains(
|
||||
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||
),
|
||||
);
|
||||
final login = analytics.events
|
||||
.where((e) => e.kind == JourneyKind.login)
|
||||
.toList();
|
||||
@@ -172,6 +197,20 @@ void main() {
|
||||
await signIn(t);
|
||||
await tapFactor(t, 'two-factor-cancel');
|
||||
expect(find.byKey(const ValueKey('email')), findsOneWidget);
|
||||
expect(
|
||||
t.testTextInput.log,
|
||||
contains(
|
||||
isMethodCall('TextInput.finishAutofillContext', arguments: false),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
t.testTextInput.log,
|
||||
isNot(
|
||||
contains(
|
||||
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
||||
expect(
|
||||
t
|
||||
|
||||
Reference in new issue
Block a user