Compare commits
3
Commits
| 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
|
Delivered in [PR #13](https://git.sendico.io/tech/sxchnge/pulls/13), branch
|
||||||
`onboarding-12`. The final browser accessibility tree exposes the recipient text,
|
`onboarding-12`. The final browser accessibility tree exposes the recipient text,
|
||||||
demo status, status heading and recovery actions. PR is open for review.
|
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.
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -63,6 +64,8 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
|
|
||||||
void _switch(bool signUp) {
|
void _switch(bool signUp) {
|
||||||
if (signUp == _signUp) return;
|
if (signUp == _signUp) return;
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
TextInput.finishAutofillContext(shouldSave: false);
|
||||||
context.read<JourneyRecorder>().abandon(JourneyExit.modeChange);
|
context.read<JourneyRecorder>().abandon(JourneyExit.modeChange);
|
||||||
context.read<LoginProvider>().reset();
|
context.read<LoginProvider>().reset();
|
||||||
context.read<SignupProvider>().reset();
|
context.read<SignupProvider>().reset();
|
||||||
@@ -95,6 +98,7 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final email = _email.text.trim();
|
final email = _email.text.trim();
|
||||||
|
TextInput.finishAutofillContext();
|
||||||
_password.clear();
|
_password.clear();
|
||||||
_confirmation.clear();
|
_confirmation.clear();
|
||||||
context.goNamed(AppRoutes.verificationSent, extra: email);
|
context.goNamed(AppRoutes.verificationSent, extra: email);
|
||||||
@@ -107,13 +111,13 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
);
|
);
|
||||||
if (!mounted || _signUp) return;
|
if (!mounted || _signUp) return;
|
||||||
if (login.state.error == AuthError.accountNotVerified) {
|
if (login.state.error == AuthError.accountNotVerified) {
|
||||||
|
TextInput.finishAutofillContext(shouldSave: false);
|
||||||
_password.clear();
|
_password.clear();
|
||||||
context.goNamed(AppRoutes.verificationSent, extra: _email.text.trim());
|
context.goNamed(AppRoutes.verificationSent, extra: _email.text.trim());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final challenge = login.state.result?.challenge;
|
final challenge = login.state.result?.challenge;
|
||||||
if (challenge != null) {
|
if (challenge != null) {
|
||||||
_password.clear();
|
|
||||||
final grant = await verifyFactor(
|
final grant = await verifyFactor(
|
||||||
context,
|
context,
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
@@ -123,6 +127,8 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
);
|
);
|
||||||
if (!mounted || _signUp) return;
|
if (!mounted || _signUp) return;
|
||||||
if (grant == null) {
|
if (grant == null) {
|
||||||
|
TextInput.finishAutofillContext(shouldSave: false);
|
||||||
|
_password.clear();
|
||||||
login.reset();
|
login.reset();
|
||||||
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
|
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
|
||||||
return;
|
return;
|
||||||
@@ -137,9 +143,13 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
!_signUp &&
|
!_signUp &&
|
||||||
identical(login.state.result, accepted) &&
|
identical(login.state.result, accepted) &&
|
||||||
login.state.status == AuthStatus.success) {
|
login.state.status == AuthStatus.success) {
|
||||||
|
TextInput.finishAutofillContext();
|
||||||
|
_password.clear();
|
||||||
context.go(offer ? '/identity?onboarding=1' : '/accounts');
|
context.go(offer ? '/identity?onboarding=1' : '/accounts');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (login.state.status == AuthStatus.success) {
|
||||||
|
TextInput.finishAutofillContext();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,7 +205,10 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
expandedInsets: EdgeInsets.zero,
|
expandedInsets: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Form(
|
AutofillGroup(
|
||||||
|
// Leaving an unfinished form must not offer to save its credentials.
|
||||||
|
onDisposeAction: AutofillContextAction.cancel,
|
||||||
|
child: Form(
|
||||||
key: _form,
|
key: _form,
|
||||||
autovalidateMode: _submitted
|
autovalidateMode: _submitted
|
||||||
? AutovalidateMode.onUserInteraction
|
? AutovalidateMode.onUserInteraction
|
||||||
@@ -220,6 +233,8 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
errorMaxLines: 3,
|
errorMaxLines: 3,
|
||||||
),
|
),
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
|
keyboardType: TextInputType.name,
|
||||||
|
autofillHints: const [AutofillHints.name],
|
||||||
textCapitalization: TextCapitalization.words,
|
textCapitalization: TextCapitalization.words,
|
||||||
validator: (value) => AuthValidation.name(value, l),
|
validator: (value) => AuthValidation.name(value, l),
|
||||||
),
|
),
|
||||||
@@ -236,6 +251,10 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
errorMaxLines: 3,
|
errorMaxLines: 3,
|
||||||
),
|
),
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
autofillHints: const [
|
||||||
|
AutofillHints.username,
|
||||||
|
AutofillHints.email,
|
||||||
|
],
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
validator: (value) => AuthValidation.email(value, l),
|
validator: (value) => AuthValidation.email(value, l),
|
||||||
@@ -246,6 +265,11 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
enabled: !state.isLoading,
|
enabled: !state.isLoading,
|
||||||
controller: _password,
|
controller: _password,
|
||||||
obscureText: !_showPassword,
|
obscureText: !_showPassword,
|
||||||
|
autofillHints: [
|
||||||
|
_signUp
|
||||||
|
? AutofillHints.newPassword
|
||||||
|
: AutofillHints.password,
|
||||||
|
],
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
enableSuggestions: false,
|
enableSuggestions: false,
|
||||||
textInputAction: _signUp
|
textInputAction: _signUp
|
||||||
@@ -281,6 +305,7 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
enabled: !state.isLoading,
|
enabled: !state.isLoading,
|
||||||
controller: _confirmation,
|
controller: _confirmation,
|
||||||
obscureText: !_showConfirmation,
|
obscureText: !_showConfirmation,
|
||||||
|
autofillHints: const [AutofillHints.newPassword],
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
enableSuggestions: false,
|
enableSuggestions: false,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
@@ -310,7 +335,8 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
key: const ValueKey('submit'),
|
key: const ValueKey('submit'),
|
||||||
onPressed: state.isLoading || state.status == AuthStatus.success
|
onPressed:
|
||||||
|
state.isLoading || state.status == AuthStatus.success
|
||||||
? null
|
? null
|
||||||
: _submit,
|
: _submit,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -331,7 +357,9 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 20,
|
width: 20,
|
||||||
height: 20,
|
height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: const Icon(Icons.arrow_forward, size: 20),
|
: const Icon(Icons.arrow_forward, size: 20),
|
||||||
],
|
],
|
||||||
@@ -341,6 +369,7 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
if (state.status == AuthStatus.success ||
|
if (state.status == AuthStatus.success ||
|
||||||
state.status == AuthStatus.error) ...[
|
state.status == AuthStatus.error) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|||||||
@@ -92,6 +92,14 @@ void main() {
|
|||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
|
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
tester
|
tester
|
||||||
.widget<TextFormField>(find.byKey(const ValueKey('email')))
|
.widget<TextFormField>(find.byKey(const ValueKey('email')))
|
||||||
@@ -153,6 +161,14 @@ void main() {
|
|||||||
'/verification-sent',
|
'/verification-sent',
|
||||||
);
|
);
|
||||||
expect(backend.sends, 1);
|
expect(backend.sends, 1);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
find.textContaining('a verification email is on its way'),
|
find.textContaining('a verification email is on its way'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
@@ -198,6 +214,14 @@ void main() {
|
|||||||
find.text('Сервис недоступен. Попробуйте ещё раз.'),
|
find.text('Сервис недоступен. Попробуйте ещё раз.'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.tap(button);
|
await tester.tap(button);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(service.calls, hasLength(2));
|
expect(service.calls, hasLength(2));
|
||||||
@@ -207,6 +231,12 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
|
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
|
||||||
expect(find.byKey(const ValueKey('auth-error')), findsNothing);
|
expect(find.byKey(const ValueKey('auth-error')), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -237,6 +267,14 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const ValueKey('confirmation')), findsOneWidget);
|
expect(find.byKey(const ValueKey('confirmation')), findsOneWidget);
|
||||||
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -55,6 +55,12 @@ void main() {
|
|||||||
await submit(tester);
|
await submit(tester);
|
||||||
expect(find.text('Enter your email address.'), findsOneWidget);
|
expect(find.text('Enter your email address.'), findsOneWidget);
|
||||||
expect(find.text('Enter your password.'), findsOneWidget);
|
expect(find.text('Enter your password.'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log.where(
|
||||||
|
(call) => call.method == 'TextInput.finishAutofillContext',
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.byKey(const ValueKey('email')),
|
find.byKey(const ValueKey('email')),
|
||||||
'test@example.com',
|
'test@example.com',
|
||||||
@@ -79,6 +85,12 @@ void main() {
|
|||||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
|
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -104,12 +116,26 @@ void main() {
|
|||||||
);
|
);
|
||||||
await submit(tester);
|
await submit(tester);
|
||||||
expect(find.text('Passwords do not match.'), findsOneWidget);
|
expect(find.text('Passwords do not match.'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.byKey(const ValueKey('confirmation')),
|
find.byKey(const ValueKey('confirmation')),
|
||||||
'example-password',
|
'example-password',
|
||||||
);
|
);
|
||||||
await submit(tester);
|
await submit(tester);
|
||||||
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.ensureVisible(
|
await tester.ensureVisible(
|
||||||
find.byKey(const ValueKey('demo-confirmation')),
|
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);
|
await signIn(t);
|
||||||
expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget);
|
expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget);
|
||||||
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
||||||
|
expect(
|
||||||
|
t.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
analytics.events.where((e) => e.kind == JourneyKind.login && e.terminal),
|
analytics.events.where((e) => e.kind == JourneyKind.login && e.terminal),
|
||||||
isEmpty,
|
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 t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456');
|
||||||
await tapFactor(t, 'two-factor-confirm');
|
await tapFactor(t, 'two-factor-confirm');
|
||||||
expect(find.byKey(const ValueKey('account-eur')), findsOneWidget);
|
expect(find.byKey(const ValueKey('account-eur')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
t.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
final login = analytics.events
|
final login = analytics.events
|
||||||
.where((e) => e.kind == JourneyKind.login)
|
.where((e) => e.kind == JourneyKind.login)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -172,6 +197,20 @@ void main() {
|
|||||||
await signIn(t);
|
await signIn(t);
|
||||||
await tapFactor(t, 'two-factor-cancel');
|
await tapFactor(t, 'two-factor-cancel');
|
||||||
expect(find.byKey(const ValueKey('email')), findsOneWidget);
|
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(find.byKey(const ValueKey('preview-result')), findsNothing);
|
||||||
expect(
|
expect(
|
||||||
t
|
t
|
||||||
|
|||||||
Reference in new issue
Block a user