import 'dart:async'; import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'package:sm_mobile/services/net/icmp_probe.dart'; /// Real ICMP from the device: Linux ping sockets on Android, Darwin ICMP /// datagram sockets on iOS. Loopback must always work. Extra targets /// (SM_ICMP_TARGETS, comma separated) are measured and printed; with /// SM_ICMP_EXPECT_ROUTERS=true at least one router must answer with Time /// Exceeded, which proves the traceroute error path on this platform. /// Results are also written to the app's temporary directory, because a /// wireless `flutter run` on iOS does not forward print output. The folder /// name makes the app delete it at the next launch or lock. final _report = File( '${Directory.systemTemp.path}/sm-transfer-devicetest/icmp.txt', ); void _log(String line) { // ignore: avoid_print print(line); _report.parent.createSync(recursive: true); _report.writeAsStringSync('$line\n', mode: FileMode.append, flush: true); } void _test(String name, Future Function(WidgetTester) body) => testWidgets(name, (tester) async { try { await body(tester); _log('PASS $name'); } catch (e) { _log('FAIL $name: $e'); rethrow; } }, timeout: const Timeout(Duration(minutes: 10))); void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); setUpAll(() { if (_report.existsSync()) _report.deleteSync(); _log( 'START ${Platform.operatingSystem} ${Platform.operatingSystemVersion}', ); }); const targets = String.fromEnvironment('SM_ICMP_TARGETS'); const expectRouters = bool.fromEnvironment('SM_ICMP_EXPECT_ROUTERS'); Future> collect(IcmpRun run) => run.events.toList().timeout(const Duration(minutes: 3)); String show(ProbeEvent e) => '${e.ttl}.${e.seq} ${e.status.name} ${e.from?.address ?? '*'} ' '${e.rttMs?.toStringAsFixed(2) ?? ''}'; _test('loopback ping (IPv4, IPv6), traceroute and cancel', (_) async { for (final target in [ InternetAddress.loopbackIPv4, InternetAddress.loopbackIPv6, ]) { final events = await collect( IcmpProbe.ping( target, const PingSettings(count: 3, interval: Duration(milliseconds: 300)), ), ); _log('ICMP ping $target: ${events.map(show).join(' | ')}'); expect(events.map((e) => e.status), everyElement(ProbeStatus.reply)); } final trace = await collect( IcmpProbe.traceroute(InternetAddress.loopbackIPv4, const TraceSettings()), ); expect( trace.every((e) => e.ttl == 1 && e.status == ProbeStatus.reply), true, ); final run = IcmpProbe.ping( InternetAddress.loopbackIPv4, const PingSettings(count: 100), ); final first = Completer(), done = Completer(); var count = 0; run.events.listen((_) { count++; if (!first.isCompleted) first.complete(); }, onDone: done.complete); await first.future; await run.cancel(); await done.future.timeout(const Duration(seconds: 2)); final after = count; await Future.delayed(const Duration(seconds: 2)); expect(count, after, reason: 'nothing after cancel'); }); _test('UDP/TCP traceroute, TTL, DF and continuous ping on loopback', ( _, ) async { final udp = await collect( IcmpProbe.traceroute( InternetAddress.loopbackIPv4, const TraceSettings(method: TraceMethod.udp, probes: 2), ), ); _log('UDP loopback: ${udp.map(show).join(' | ')}'); expect(udp.every((e) => e.note == 'portUnreachable'), true); if (IcmpProbe.unsupported(TraceMethod.tcp) == null) { final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); final tcp = await collect( IcmpProbe.traceroute( InternetAddress.loopbackIPv4, TraceSettings(method: TraceMethod.tcp, port: server.port, probes: 2), ), ); await server.close(); _log('TCP loopback: ${tcp.map(show).join(' | ')}'); expect(tcp.every((e) => e.note == 'tcpOpen'), true); } final ttl = await collect( IcmpProbe.ping( InternetAddress.loopbackIPv4, const PingSettings(count: 2, ttl: 5, dontFragment: true), ), ); expect(ttl.every((e) => e.status == ProbeStatus.reply), true); final run = IcmpProbe.ping( InternetAddress.loopbackIPv4, const PingSettings(count: 0, interval: Duration(milliseconds: 200)), ); final got = []; final done = Completer(); run.events.listen(got.add, onDone: done.complete); await Future.delayed(const Duration(milliseconds: 1500)); await run.cancel(); await done.future.timeout(const Duration(seconds: 2)); expect(got.length, greaterThanOrEqualTo(4)); }); _test('measurements to configured targets', (_) async { var routers = 0; for (final name in targets.split(',').where((t) => t.isNotEmpty)) { final address = InternetAddress(name); try { final ping = await collect( IcmpProbe.ping(address, const PingSettings(count: 3)), ); final stats = PingStats.of(ping); _log( 'ICMP ping $name: sent ${stats.sent} received ${stats.received} ' 'avg ${stats.avg?.toStringAsFixed(2)} ms', ); final trace = await collect( IcmpProbe.traceroute( address, const TraceSettings(maxHops: 20, timeout: Duration(seconds: 1)), ), ); routers += trace .where((e) => e.status == ProbeStatus.timeExceeded) .length; // The other probe methods where the platform supports them. for (final method in [TraceMethod.udp, TraceMethod.tcp]) { final why = IcmpProbe.unsupported(method); if (why != null) { _log('$method $name: not supported here ($why)'); continue; } final other = await collect( IcmpProbe.traceroute( address, TraceSettings( method: method, maxHops: 20, probes: 2, timeout: const Duration(seconds: 1), port: method == TraceMethod.tcp ? 443 : null, ), ), ); routers += other .where((e) => e.status == ProbeStatus.timeExceeded) .length; _log('$method trace $name:\n${other.map(show).join('\n')}'); expect( other.any((e) => e.status == ProbeStatus.reply), true, reason: '$method: destination reached', ); } _log('ICMP trace $name:\n${trace.map(show).join('\n')}'); } on IcmpUnavailable catch (e) { _log('ICMP $name unavailable: $e'); rethrow; } } if (expectRouters) expect(routers, greaterThan(0)); }); }