utils/github_device_auth/test/device_code_dialog_test.dart
Krzysztof kuhy Rudnicki 3084080d74
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
github_device_auth tests / test (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
sync_settings_ui 0.7.0, gda 0.4.0: text-free
Glyph + spoken label replaces every app-written string on the shared
Sync settings screen and the GitHub device-code dialog (kuhy's
textgate rule). Old sentences are semantics labels; tests find them
there. The device user code stays visible (GitHub's data).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 01:27:57 +01:00

173 lines
5.5 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:github_device_auth/github_device_auth.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
const _device = DeviceCodeResponse(
deviceCode: 'dc',
userCode: 'ABCD-1234',
verificationUri: 'https://github.com/login/device',
interval: 1,
expiresIn: 60,
);
/// Completes immediately -- for tests whose mock resolves on the first poll.
Future<void> _noDelay(Duration _) async {}
/// Never completes, so the poll loop suspends on its first delay.
///
/// The pending-state tests need the dialog to sit in "waiting" without
/// actually looping: with an instantly-completing delay and a mock that
/// always answers `authorization_pending`, `pollForToken` spins as fast as
/// the event loop allows and starves the test binding.
Future<void> _neverDelay(Duration _) => Completer<void>().future;
GitHubDeviceAuth _auth(
MockClient client, {
Future<void> Function(Duration) delay = _noDelay,
}) => GitHubDeviceAuth(clientId: 'cid', httpClient: client, delay: delay);
/// An auth client that never gets past its first poll delay.
GitHubDeviceAuth _pendingAuth() => _auth(
MockClient(
(_) async => http.Response(jsonEncode({'error': 'authorization_pending'}), 200),
),
delay: _neverDelay,
);
/// Opens the dialog and returns a getter for whatever it eventually pops.
///
/// The result is read through a closure rather than returned directly
/// because the dialog may still be polling when this returns -- a test that
/// cares about the popped value pumps first, then reads.
Future<Object? Function()> _showDialog(
WidgetTester tester,
GitHubDeviceAuth auth,
) async {
Object? popped;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => ElevatedButton(
onPressed: () async {
popped = await showDialog<String>(
context: context,
builder: (_) => DeviceCodeDialog(device: _device, auth: auth),
);
},
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pump();
// Let the dialog fade in: a transparent route has no semantics yet, and
// the dialog's words are spoken labels now.
await tester.pump(const Duration(milliseconds: 300));
return () => popped;
}
void main() {
testWidgets('shows the user code and a waiting state', (tester) async {
await _showDialog(tester, _pendingAuth());
expect(find.bySemanticsLabel('Authorize on GitHub'), findsOneWidget);
expect(find.text('ABCD-1234'), findsOneWidget);
expect(find.bySemanticsLabel('Waiting for authorization…'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('pops the token once authorized', (tester) async {
final auth = _auth(
MockClient(
(_) async => http.Response(jsonEncode({'access_token': 'tok'}), 200),
),
);
final popped = await _showDialog(tester, auth);
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
expect(popped(), 'tok');
});
testWidgets('shows the error instead of spinning forever', (tester) async {
final auth = _auth(
MockClient(
(_) async => http.Response(
jsonEncode({
'error': 'access_denied',
'error_description': 'user declined',
}),
200,
),
),
);
await _showDialog(tester, auth);
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.bySemanticsLabel(RegExp('access_denied')), findsOneWidget);
});
testWidgets('surfaces an Error, not just an Exception', (tester) async {
// The regression this dialog's `on Object catch` exists for: an
// `ArgumentError` is an Error, not an Exception, so `on Exception` would
// let it escape and leave the dialog spinning with nothing shown.
final auth = _auth(
MockClient((_) async => throw ArgumentError('No host specified in URI')),
);
await _showDialog(tester, auth);
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.bySemanticsLabel(RegExp('No host specified')), findsOneWidget);
});
testWidgets('cancel pops without a token', (tester) async {
final popped = await _showDialog(tester, _pendingAuth());
await tester.tap(find.bySemanticsLabel('Cancel'));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
expect(popped(), isNull);
});
testWidgets('the action button copies the code to the clipboard', (
tester,
) async {
final auth = _pendingAuth();
String? copied;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'Clipboard.setData') {
copied =
((call.arguments as Map<Object?, Object?>)['text'] as String?);
}
return null;
},
);
// url_launcher would otherwise hit a missing plugin on the test binding.
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/url_launcher'),
(_) async => true,
);
await _showDialog(tester, auth);
await tester.tap(find.bySemanticsLabel('Open GitHub & copy code'));
await tester.pump();
expect(copied, 'ABCD-1234');
});
}