todo-app/test/settings_screen_test.dart
Krzysztof kuhy Rudnicki d316563015
Some checks are pending
release-apk / release (APK) (push) Waiting to run
Fix desktop Firebase sync: seed a Google-derived session, not a dead password
The account's password grant is retired fleet-wide (see link_google.py /
seed_session.py in ~/utils/crdt-sync), so desktop's Firebase form could never
have connected regardless of provisioning. Desktop-as-web can't use the
in-browser Google Sign-In button either (google_platform_web.dart hard-codes
googleSignInSupported = false), so it needs the same session-seeding path the
Python daemons already use: seed_session.py now includes todo, and the
wrapper's new /sync-credentials route serves that seeded session to
loadAccount() once per launch, then closes for the rest of the process.

Also stops the status line reporting a bare "Synced" while running over the
GitHub mirror alone with Firebase disconnected -- SyncResult now carries
firebaseConnected so the UI can say "GitHub-only (Firebase not connected)".

Includes the prior session's Google Sign-In one-tap addition
(google_sign_in_backend.dart and friends), which this builds on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPsyKQRX8ZwJpUqg37jp83
2026-08-12 19:35:18 +02:00

802 lines
27 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:io';
import 'dart:convert';
import 'package:file_selector_platform_interface/file_selector_platform_interface.dart';
import 'package:crdt_sync/crdt_sync.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:todo/sync/backlog_export_io.dart';
import 'package:todo/data/note.dart';
import 'package:todo/data/note_repository.dart';
import 'package:todo/sync/notes_markdown.dart';
import 'package:todo/sync/sync_settings.dart';
import 'package:todo/ui/settings_screen.dart';
import 'package:url_launcher_platform_interface/link.dart';
import 'package:url_launcher_platform_interface/url_launcher_platform_interface.dart';
import 'fake_note_repository.dart';
import 'fake_secure_storage.dart';
/// Stub file picker that returns a fixed in-memory file (no disk I/O, so the
/// `_import` flow stays timer-free and deterministic under the widget tester).
class _FakeFileSelector extends FileSelectorPlatform
with MockPlatformInterfaceMixin {
_FakeFileSelector(this.file);
final XFile? file;
@override
Future<XFile?> openFile({
List<XTypeGroup>? acceptedTypeGroups,
String? initialDirectory,
String? confirmButtonText,
}) async => file;
}
/// Repository whose reads fail, to exercise the export error path.
class _ExplodingRepo extends FakeNoteRepository {
@override
Future<List<Note>> listNotes({
NoteSort sort = NoteSort.modifiedDesc,
NoteFilter filter = const NoteFilter(),
}) async => throw Exception('db down');
}
/// File picker stub that throws, to exercise the import error path.
class _ThrowingFileSelector extends FileSelectorPlatform
with MockPlatformInterfaceMixin {
@override
Future<XFile?> openFile({
List<XTypeGroup>? acceptedTypeGroups,
String? initialDirectory,
String? confirmButtonText,
}) async => throw Exception('picker blew up');
}
/// Stub launcher that records the URL instead of opening it, so `_openPage`
/// can be exercised without a real platform channel.
class _FakeUrlLauncher extends UrlLauncherPlatform
with MockPlatformInterfaceMixin {
String? launched;
@override
final LinkDelegate? linkDelegate = null;
@override
Future<bool> supportsMode(PreferredLaunchMode mode) async => true;
@override
Future<bool> launchUrl(String url, LaunchOptions options) async {
launched = url;
return true;
}
}
void main() {
Future<FakeNoteRepository> pumpSettings(
WidgetTester tester, {
SyncSettings initial = const SyncSettings(
owner: 'kuhyx',
repo: 'syncs',
token: 't',
),
http.Client? httpClient,
List<Note> seed = const [],
FakeNoteRepository? repository,
Future<FirebaseRestClient?> Function()? firebaseFactory,
Future<FirebaseRestClient?> Function()? googleFirebaseFactory,
bool? googleAvailable,
Future<FirebaseAccount?> Function()? accountLoader,
Future<void> Function(FirebaseAccount)? accountSaver,
Future<void> Function()? accountClearer,
Future<bool> Function()? sessionProbe,
}) async {
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage();
// Tall surface so the whole settings ListView builds (its Backup section
// is below the default 800×600 fold and would otherwise be lazy-skipped).
tester.view.physicalSize = const Size(1200, 2800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final repo = repository ?? FakeNoteRepository(seed);
addTearDown(repo.close);
await tester.pumpWidget(
MaterialApp(
home: SettingsScreen(
initial: initial,
repository: repo,
httpClient: httpClient,
// Both injected so the widget never reaches for the platform: the
// real factories want the OS keystore and an application-support
// directory, neither of which exists under `flutter test`.
firebaseFactory: firebaseFactory ?? () async => null,
googleFirebaseFactory: googleFirebaseFactory ?? () async => null,
googleAvailable: googleAvailable ?? true,
accountLoader: accountLoader ?? () async => null,
accountSaver: accountSaver,
accountClearer: accountClearer,
// Defaults to whatever the injected account says, so a test that
// only stubs the account still describes one coherent device. The
// production probe consults the keystore, which these tests fake
// rather than provide, and would otherwise answer "no session" for
// a device the test just declared signed in.
sessionProbe:
sessionProbe ??
() async => await (accountLoader ?? () async => null)() != null,
stateStore: InMemorySyncStateStore(),
),
),
);
await tester.pump();
return repo;
}
/// Expands "Use the account password instead".
///
/// Google sign-in is the primary path now, so the email/password form is
/// collapsed by default; a test that types credentials has to open it.
Future<void> openPasswordForm(WidgetTester tester) async {
await tester.tap(find.text('Use the account password instead'));
await tester.pumpAndSettle();
}
/// Expands the "Advanced (GitHub mirror)" section.
///
/// GitHub is the cutover mirror rather than a choice the user makes, so
/// everything GitHub-facing is collapsed by default; a test that touches
/// those widgets has to open it first.
Future<void> openAdvanced(WidgetTester tester) async {
await tester.tap(find.text('Advanced (GitHub mirror)'));
await tester.pumpAndSettle();
}
testWidgets('renders sync fields and the backup actions', (tester) async {
await pumpSettings(tester);
// GitHub is the mirror now: hidden until the disclosure is opened. The
// password form is likewise behind one, leaving Google as the only
// sign-in control on screen.
expect(find.text('Connect GitHub'), findsNothing);
expect(find.text('Connect Firebase'), findsNothing);
expect(find.text('Sign in with Google'), findsOneWidget);
await openAdvanced(tester);
expect(find.text('Connect GitHub'), findsOneWidget);
expect(find.text('Export notes'), findsOneWidget);
expect(find.text('Import notes'), findsOneWidget);
});
testWidgets('offers Google first and the password form behind it', (
tester,
) async {
// Ordering is the feature: after a reinstall the one-tap path has to be
// the thing you see, with the phone-keyboard path available but demoted.
await pumpSettings(tester);
expect(find.text('Sign in with Google'), findsOneWidget);
expect(
find.widgetWithText(TextField, 'Sync account email'),
findsNothing,
reason: 'the password form starts collapsed',
);
await openPasswordForm(tester);
expect(
find.widgetWithText(TextField, 'Sync account email'),
findsOneWidget,
);
expect(
find.widgetWithText(TextField, 'Sync account password'),
findsOneWidget,
);
expect(find.text('Disconnect'), findsNothing);
});
testWidgets('hides Google and opens the password form on desktop', (
tester,
) async {
// The desktop build is the web build, where Google Identity Services only
// signs in through its own rendered button -- `authenticate()` throws
// UnimplementedError there, and not a GoogleSignInException, so it would
// not even have been caught. Offering a button that always crashed would
// be worse than offering none, and the password form has to be visible
// because it is then the only way in.
await pumpSettings(tester, googleAvailable: false);
expect(find.text('Sign in with Google'), findsNothing);
expect(
find.widgetWithText(TextField, 'Sync account email'),
findsOneWidget,
reason: 'the password form is expanded when it is the only option',
);
});
testWidgets('Sign in with Google connects without typing anything', (
tester,
) async {
// The whole feature in one test: no text entered anywhere, one tap, and
// the device ends up connected showing the account Google signed in as.
//
// The email comes from Firebase's response via openFirebaseWithGoogle,
// not from the form -- a fresh install has nothing in the form, which is
// exactly the scenario this path exists for. An earlier cut read the
// controller here and persisted an empty account; the next launch then
// took the password branch with '' and failed.
// Starts genuinely unconfigured -- a fresh install -- and only gains an
// account as a side effect of the Google sign-in itself.
FirebaseAccount? stored;
await pumpSettings(
tester,
googleFirebaseFactory: () async {
stored = const FirebaseAccount(
email: 'signed-in@example.com',
password: '',
);
return _stubClient();
},
accountLoader: () async => stored,
);
await tester.tap(find.text('Sign in with Google'));
await tester.pumpAndSettle();
expect(find.text('Connected to Firebase.'), findsOneWidget);
expect(find.text('signed-in@example.com'), findsOneWidget);
});
testWidgets('a cancelled Google sign-in is not reported as an error', (
tester,
) async {
// Dismissing the account picker is an ordinary thing to do; it must not
// look like a failure.
await pumpSettings(tester, googleFirebaseFactory: () async => null);
await tester.tap(find.text('Sign in with Google'));
await tester.pumpAndSettle();
expect(find.text('Google sign-in was cancelled.'), findsOneWidget);
expect(find.text('Sign in with Google'), findsOneWidget);
});
testWidgets('a wrong-account Google sign-in explains itself', (tester) async {
// The failure worth being loud about: authentication succeeds but the uid
// is not the one the security rules pin, so every read and write would be
// denied with no other symptom.
await pumpSettings(
tester,
googleFirebaseFactory: () async =>
throw FirebaseAuthError('signed in as the wrong account: uid X'),
);
await tester.tap(find.text('Sign in with Google'));
await tester.pumpAndSettle();
expect(find.textContaining('wrong account'), findsOneWidget);
});
testWidgets('Connect Firebase with empty fields asks for credentials', (
tester,
) async {
await pumpSettings(tester);
await openPasswordForm(tester);
await tester.tap(find.text('Connect Firebase'));
await tester.pump();
expect(
find.textContaining('Enter the sync account email and password'),
findsOneWidget,
);
});
testWidgets('Connect Firebase stores the account and reports success', (
tester,
) async {
FirebaseAccount? saved;
await pumpSettings(
tester,
accountSaver: (a) async => saved = a,
firebaseFactory: () async => _stubClient(),
);
await openPasswordForm(tester);
await tester.enterText(
find.widgetWithText(TextField, 'Sync account email'),
'sync@example.com',
);
await tester.enterText(
find.widgetWithText(TextField, 'Sync account password'),
'pw',
);
await tester.tap(find.text('Connect Firebase'));
await tester.pumpAndSettle();
// saveAccount must actually be called: without it openFirebase() reads an
// account nothing ever wrote, and Firebase is a silent no-op forever.
expect(saved?.email, 'sync@example.com');
expect(saved?.password, 'pw');
expect(find.text('Connected to Firebase.'), findsOneWidget);
// Connected state is read-only text, not a form implying re-entry.
expect(find.text('sync@example.com'), findsOneWidget);
expect(find.text('Disconnect'), findsOneWidget);
expect(
find.widgetWithText(TextField, 'Sync account password'),
findsNothing,
);
});
testWidgets('a rejected account is cleared rather than left half-stored', (
tester,
) async {
var cleared = false;
await pumpSettings(
tester,
accountSaver: (_) async {},
accountClearer: () async => cleared = true,
firebaseFactory: () async => null,
);
await openPasswordForm(tester);
await tester.enterText(
find.widgetWithText(TextField, 'Sync account email'),
'wrong@example.com',
);
await tester.enterText(
find.widgetWithText(TextField, 'Sync account password'),
'bad',
);
await tester.tap(find.text('Connect Firebase'));
await tester.pumpAndSettle();
expect(cleared, isTrue);
expect(find.text('Firebase rejected that account.'), findsOneWidget);
});
testWidgets('a stored account shows as connected on open, and disconnects', (
tester,
) async {
var cleared = false;
await pumpSettings(
tester,
accountLoader: () async =>
const FirebaseAccount(email: 'stored@example.com', password: 'pw'),
accountClearer: () async => cleared = true,
);
await tester.pumpAndSettle();
expect(find.text('stored@example.com'), findsOneWidget);
await tester.tap(find.text('Disconnect'));
await tester.pumpAndSettle();
expect(cleared, isTrue);
expect(find.text('Firebase disconnected.'), findsOneWidget);
// Back to the not-connected state, which now leads with Google rather
// than with the email field.
expect(find.text('Sign in with Google'), findsOneWidget);
await openPasswordForm(tester);
expect(
find.widgetWithText(TextField, 'Sync account email'),
findsOneWidget,
);
});
testWidgets('Connect GitHub without a client id shows guidance', (
tester,
) async {
await pumpSettings(tester);
await openAdvanced(tester);
await tester.tap(find.text('Connect GitHub'));
await tester.pump();
expect(
find.textContaining('Enter the OAuth App client id'),
findsOneWidget,
);
});
testWidgets('Test connection reports a reachable repo', (tester) async {
final mock = MockClient((_) async => http.Response('{}', 200));
await pumpSettings(tester, httpClient: mock);
await tester.tap(
find.text('Advanced (GitHub mirror)'),
); // Test connection lives here now
await tester.pumpAndSettle();
await tester.tap(find.text('Test connection'));
await tester.pump(); // start
await tester.pump(); // resolve future + rebuild
expect(find.textContaining('reachable'), findsOneWidget);
});
testWidgets('Test connection reports an inaccessible repo', (tester) async {
final mock = MockClient((_) async => http.Response('', 404));
await pumpSettings(tester, httpClient: mock);
await tester.tap(find.text('Advanced (GitHub mirror)'));
await tester.pumpAndSettle();
await tester.tap(find.text('Test connection'));
await tester.pump();
await tester.pump();
expect(find.textContaining('Could not access'), findsOneWidget);
});
testWidgets('Test connection surfaces a network error', (tester) async {
final mock = MockClient((_) async => throw Exception('offline'));
await pumpSettings(tester, httpClient: mock);
await tester.tap(find.text('Advanced (GitHub mirror)'));
await tester.pumpAndSettle();
await tester.tap(find.text('Test connection'));
await tester.pump();
await tester.pump();
expect(find.textContaining('Error:'), findsOneWidget);
});
testWidgets('device flow failure to start shows a message', (tester) async {
final mock = MockClient((_) async => http.Response('nope', 422));
await pumpSettings(
tester,
initial: const SyncSettings(
owner: 'o',
repo: 'r',
token: '',
clientId: 'cid',
),
httpClient: mock,
);
await openAdvanced(tester);
await tester.tap(find.text('Connect GitHub'));
await tester.pump();
await tester.pump();
expect(find.textContaining('Could not start device flow'), findsOneWidget);
});
testWidgets('device flow happy path saves the token', (tester) async {
final mock = MockClient((req) async {
if (req.url.path.contains('device/code')) {
return http.Response(
jsonEncode({
'device_code': 'dev123',
'user_code': 'WXYZ-1234',
'verification_uri': 'https://github.com/login/device',
'interval': 0,
'expires_in': 900,
}),
200,
);
}
// Token endpoint: authorize immediately.
return http.Response(jsonEncode({'access_token': 'gho_test'}), 200);
});
await pumpSettings(
tester,
initial: const SyncSettings(
owner: 'o',
repo: 'r',
token: '',
clientId: 'cid',
),
httpClient: mock,
);
await openAdvanced(tester);
await tester.tap(find.text('Connect GitHub'));
await tester.pump(); // requestDeviceCode
await tester.pump(); // dialog builds, shows the user code
expect(find.text('WXYZ-1234'), findsOneWidget);
// Let the dialog poll (interval 0) and resolve the token, then the
// post-connect sync runs against the mock (list → empty, then PUT).
await tester.pump(const Duration(milliseconds: 50));
for (var i = 0; i < 6; i++) {
await tester.pump();
}
expect(find.textContaining('Connected and synced'), findsOneWidget);
});
testWidgets('device flow connects but surfaces a post-connect sync failure', (
tester,
) async {
final mock = MockClient((req) async {
if (req.url.path.contains('device/code')) {
return http.Response(
jsonEncode({
'device_code': 'dev123',
'user_code': 'WXYZ-1234',
'verification_uri': 'https://github.com/login/device',
'interval': 0,
'expires_in': 900,
}),
200,
);
}
if (req.url.path.contains('login/oauth/access_token')) {
return http.Response(jsonEncode({'access_token': 'gho_test'}), 200);
}
return http.Response('boom', 500); // the sync's repo calls fail
});
await pumpSettings(
tester,
initial: const SyncSettings(
owner: 'o',
repo: 'r',
token: '',
clientId: 'cid',
),
httpClient: mock,
);
await openAdvanced(tester);
await tester.tap(find.text('Connect GitHub'));
await tester.pump();
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
for (var i = 0; i < 6; i++) {
await tester.pump();
}
expect(find.textContaining('sync failed'), findsOneWidget);
});
test('the default export home is the real HOME', () {
// Guards the production default, which every other test overrides so the
// suite cannot write to the user's ~/todo.
expect(resolveExportHome(), Platform.environment['HOME']);
});
testWidgets('Export notes writes the backlog file (desktop)', (tester) async {
// Redirect HOME: this test does real file I/O, and without the override it
// overwrites the user's canonical ~/todo/BACKLOG.md with the fake note
// below on every test run.
// Sync creation: awaiting real file I/O under the widget tester's fake
// clock never completes.
final home = Directory.systemTemp.createTempSync('todo_export_home');
resolveExportHome = () => home.path;
addTearDown(() {
resolveExportHome = () =>
Platform.environment['HOME'] ?? Directory.current.path;
home.deleteSync(recursive: true);
});
await pumpSettings(
tester,
seed: [
Note(
id: 'n',
text: 'an idea',
priority: Priority.medium,
status: Status.todo,
createdAt: DateTime(2026, 6, 15),
updatedAt: DateTime(2026, 6, 15),
),
],
);
// _export does real file I/O on desktop, so drive it under runAsync.
await tester.runAsync(() async {
await tester.tap(find.text('Export notes'));
await Future<void>.delayed(const Duration(milliseconds: 50));
});
await tester.pump();
expect(find.textContaining('Exported'), findsOneWidget);
});
testWidgets('Export surfaces a failure when the repository read fails', (
tester,
) async {
await pumpSettings(tester, repository: _ExplodingRepo());
await tester.runAsync(() async {
await tester.tap(find.text('Export notes'));
await Future<void>.delayed(const Duration(milliseconds: 50));
});
await tester.pump();
expect(find.textContaining('Export failed'), findsOneWidget);
});
testWidgets('Save persists the settings and closes the screen', (
tester,
) async {
SharedPreferences.setMockInitialValues({});
installFakeSecureStorage();
tester.view.physicalSize = const Size(1200, 2800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final repo = FakeNoteRepository();
addTearDown(repo.close);
// Push Settings over a base route so _save's Navigator.pop has somewhere
// to return to (popping the root route is a no-op and hides the result).
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<SyncSettings>(
builder: (_) => SettingsScreen(
initial: const SyncSettings(
owner: 'o',
repo: 'r',
token: 'tok',
),
repository: repo,
firebaseFactory: () async => null,
stateStore: InMemorySyncStateStore(),
),
),
),
child: const Text('open'),
),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400)); // route transition
expect(find.text('Firebase sync'), findsOneWidget); // settings is up
await tester.tap(find.text('Save'));
await tester.pump(); // run _save (persist + pop)
await tester.pump(const Duration(milliseconds: 400)); // pop transition
expect(find.text('open'), findsOneWidget); // back on the base route
final saved = await SyncSettings.load();
expect(saved.owner, 'o');
expect(saved.token, 'tok');
});
testWidgets('Import notes reads the picked file and merges', (tester) async {
// Round-trip a known note through the export format so the picked file is
// valid input the importer can parse and merge.
final markdown = NotesMarkdown.export([
Note(
id: 'imported-1',
text: 'an imported idea',
priority: Priority.high,
status: Status.inProgress,
createdAt: DateTime(2026, 6, 15),
updatedAt: DateTime(2026, 6, 15),
),
]);
FileSelectorPlatform.instance = _FakeFileSelector(
XFile.fromData(utf8.encode(markdown), name: 'backlog.md'),
);
final repo = await pumpSettings(tester);
await tester.tap(find.text('Import notes'));
await tester.pump(); // openFile resolves (in-memory)
await tester.pump(); // read + parse + merge + setState
expect(find.textContaining('Imported'), findsOneWidget);
expect((await repo.listNotes()).single.text, 'an imported idea');
});
testWidgets('Import shows nothing when the picker is cancelled', (
tester,
) async {
FileSelectorPlatform.instance = _FakeFileSelector(null); // user cancels
final repo = await pumpSettings(tester);
await tester.tap(find.text('Import notes'));
await tester.pump();
await tester.pump();
expect(find.textContaining('Imported'), findsNothing);
expect(await repo.listNotes(), isEmpty);
});
testWidgets('Import surfaces a failure from the picker', (tester) async {
FileSelectorPlatform.instance = _ThrowingFileSelector();
final repo = await pumpSettings(tester);
await tester.tap(find.text('Import notes'));
await tester.pump();
await tester.pump();
expect(find.textContaining('Import failed'), findsOneWidget);
expect(await repo.listNotes(), isEmpty);
});
testWidgets('device dialog: failed poll shows the error and Open launches', (
tester,
) async {
final launcher = _FakeUrlLauncher();
UrlLauncherPlatform.instance = launcher;
// _openPage copies the code to the clipboard first; there's no clipboard
// plugin in the test host, so stub the channel to succeed.
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async => null,
);
addTearDown(
() => messenger.setMockMethodCallHandler(SystemChannels.platform, null),
);
final mock = MockClient((req) async {
if (req.url.path.contains('device/code')) {
return http.Response(
jsonEncode({
'device_code': 'dev123',
'user_code': 'WXYZ-1234',
'verification_uri': 'https://github.com/login/device',
'interval': 0,
'expires_in': 900,
}),
200,
);
}
// Token endpoint: a terminal error ends the poll loop cleanly (no
// lingering timer to trip the tester's pending-timer guard).
return http.Response(
jsonEncode({'error': 'access_denied', 'error_description': 'nope'}),
200,
);
});
await pumpSettings(
tester,
initial: const SyncSettings(
owner: 'o',
repo: 'r',
token: '',
clientId: 'cid',
),
httpClient: mock,
);
await openAdvanced(tester);
await tester.tap(find.text('Connect GitHub'));
await tester.pump(); // requestDeviceCode
await tester.pump(); // dialog builds, poll starts (interval 0)
expect(find.text('WXYZ-1234'), findsOneWidget);
await tester.pump(const Duration(milliseconds: 1)); // _delay(0) fires
await tester.pump(); // token error throws → _error set
expect(find.textContaining('nope'), findsOneWidget); // error rendered
// Tap the "open on GitHub" action: copies the code and launches the URL.
// _openPage awaits Clipboard.setData then the launcher's supportsMode +
// launchUrl; pumpAndSettle drains them (no spinner is animating now that
// the error is shown, so it settles).
await tester.tap(find.byIcon(Icons.open_in_new));
await tester.pumpAndSettle();
expect(launcher.launched, 'https://github.com/login/device');
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle(); // finish the dialog pop animation
expect(find.text('WXYZ-1234'), findsNothing); // dialog dismissed
});
}
/// A real [FirebaseRestClient] that is never called: the settings screen only
/// checks it for null, so no network or keystore is touched.
FirebaseRestClient _stubClient() => FirebaseRestClient(
databaseUrl: 'https://example.invalid',
auth: FirebaseTokenProvider(
apiKey: 'k',
store: InMemoryCredentialStore(),
),
);