feat: add a headless entry point that runs a test with no UI
Separation that nothing exercises is separation that quietly stops being true, so this runs a full test through Iperf3Service and Iperf3Session with WidgetsFlutterBinding but no runApp: no widget tree, no render surface. If the run path ever starts needing the UI, this stops working. An architecture test additionally forbids it importing lib/ui. It is also the accurate way to measure. A 4-stream run against a local server reports 30.2% local CPU here, against 79.9% for a comparable run with the transcript repainting alongside — the UI was costing roughly fifty points of CPU on a phone already near CPU-bound at gigabit rates. Throughput figures taken through the UI are therefore pessimistic, and this is the path to use when the number matters. Parameters come from --dart-define so a run needs no code edit: flutter run -t lib/main_headless.dart --dart-define=IPERF_SERVER=… Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
// Headless entry point: runs one iperf3 test with no widgets.
|
||||
//
|
||||
// This exists to keep the core honest. If anything in the run path ever starts
|
||||
// depending on the UI, this stops compiling or stops working — a claim about
|
||||
// separation that is checked rather than asserted.
|
||||
//
|
||||
// It is also the low-noise way to measure. The UI competes with the test for
|
||||
// CPU (a gigabit run can put the phone near CPU-bound), so figures taken here
|
||||
// are cleaner than figures taken with a transcript repainting alongside.
|
||||
//
|
||||
// flutter run -t lib/main_headless.dart \
|
||||
// --dart-define=IPERF_SERVER=192.168.2.182 \
|
||||
// --dart-define=IPERF_STREAMS=4
|
||||
//
|
||||
// Nothing below imports lib/ui, and test/architecture_test.dart enforces that.
|
||||
|
||||
import 'package:flutter/widgets.dart' show WidgetsFlutterBinding;
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
|
||||
import 'package:iperf3gui/data/services/iperf3_service.dart';
|
||||
import 'package:iperf3gui/domain/models/iperf3_event.dart';
|
||||
import 'package:iperf3gui/domain/models/iperf3_session.dart';
|
||||
import 'package:iperf3gui/domain/models/test_configuration.dart';
|
||||
import 'package:iperf3gui/domain/services/iperf3_runner.dart';
|
||||
|
||||
/// Test parameters, supplied at build time with --dart-define.
|
||||
const String _server = String.fromEnvironment('IPERF_SERVER',
|
||||
defaultValue: 'iperf.es.net');
|
||||
const int _port = int.fromEnvironment('IPERF_PORT', defaultValue: 5201);
|
||||
const int _seconds = int.fromEnvironment('IPERF_SECONDS', defaultValue: 10);
|
||||
const int _streams = int.fromEnvironment('IPERF_STREAMS', defaultValue: 1);
|
||||
|
||||
Future<void> main() async {
|
||||
// Required for the MethodChannel. Notably NOT runApp() — there is no widget
|
||||
// tree, no render surface and no Flutter UI thread work in this process.
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const config = TestConfiguration(
|
||||
server: _server,
|
||||
port: _port,
|
||||
duration: Duration(seconds: _seconds),
|
||||
parallelStreams: _streams,
|
||||
);
|
||||
|
||||
final Iperf3Runner runner = Iperf3Service();
|
||||
final session = Iperf3Session();
|
||||
|
||||
final subscription = runner.events.listen((event) {
|
||||
session.add(event);
|
||||
if (event is Iperf3Interval && !event.omitted) {
|
||||
_out('interval ${event.start.toStringAsFixed(2)}-'
|
||||
'${event.end.toStringAsFixed(2)}s '
|
||||
'${_mbits(event.bitsPerSecond)} Mbit/s'
|
||||
'${event.retransmits != null ? ' retr=${event.retransmits}' : ''}');
|
||||
} else if (event is Iperf3Error) {
|
||||
_out('error: ${event.message}');
|
||||
}
|
||||
});
|
||||
|
||||
_out('running iperf3 -c ${config.server} -p ${config.port} '
|
||||
'-t ${config.duration.inSeconds} -P ${config.parallelStreams}');
|
||||
|
||||
final result = await runner.run(config);
|
||||
await subscription.cancel();
|
||||
await runner.dispose();
|
||||
|
||||
_report(result.exitCode, session);
|
||||
}
|
||||
|
||||
void _report(int exitCode, Iperf3Session session) {
|
||||
_out('--- result ---');
|
||||
_out('exit code: $exitCode');
|
||||
_out('samples: ${session.sampleCount}');
|
||||
|
||||
final mean = session.meanBitsPerSecond;
|
||||
final peak = session.peakBitsPerSecond;
|
||||
if (mean != null) _out('mean: ${_mbits(mean)} Mbit/s');
|
||||
if (peak != null) _out('peak: ${_mbits(peak)} Mbit/s');
|
||||
|
||||
final retr = session.totalRetransmits;
|
||||
_out('retransmits: ${retr ?? 'not measured'}');
|
||||
|
||||
final sent = session.summary?.sent;
|
||||
final received = session.summary?.received;
|
||||
if (sent != null) {
|
||||
_out('sent: ${_mbits(sent.bitsPerSecond)} Mbit/s '
|
||||
'over ${sent.seconds.toStringAsFixed(2)}s');
|
||||
}
|
||||
if (received != null) {
|
||||
_out('received: ${_mbits(received.bitsPerSecond)} Mbit/s '
|
||||
'over ${received.seconds.toStringAsFixed(2)}s');
|
||||
}
|
||||
final cpu = session.summary?.hostCpuPercent;
|
||||
if (cpu != null) _out('local cpu: ${cpu.toStringAsFixed(1)}%');
|
||||
|
||||
if (session.stopped) _out('ended early: stopped');
|
||||
for (final error in session.errors) {
|
||||
_out('error: $error');
|
||||
}
|
||||
}
|
||||
|
||||
String _mbits(double bitsPerSecond) =>
|
||||
(bitsPerSecond / 1e6).toStringAsFixed(1);
|
||||
|
||||
void _out(String line) => debugPrint('[iperf3-headless] $line');
|
||||
@@ -82,6 +82,22 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('the headless entry point does not depend on ui', () {
|
||||
// main_headless.dart is the standing proof that the core runs without a
|
||||
// widget tree; an import from lib/ui would quietly end that.
|
||||
final offenders = _importsMatching(
|
||||
'lib',
|
||||
(import) => import.contains('/ui/'),
|
||||
onlyFiles: {'lib/main_headless.dart'},
|
||||
);
|
||||
expect(
|
||||
offenders,
|
||||
isEmpty,
|
||||
reason: 'The headless entry point must run the core with no UI.\n'
|
||||
'${_render(offenders)}',
|
||||
);
|
||||
});
|
||||
|
||||
test('every layer directory is actually covered by these rules', () {
|
||||
// Guards against a new top-level layer appearing and silently escaping
|
||||
// every rule above.
|
||||
@@ -102,8 +118,9 @@ void main() {
|
||||
/// Every `(file, import)` pair under [directory] whose import satisfies [test].
|
||||
List<({String file, String import})> _importsMatching(
|
||||
String directory,
|
||||
bool Function(String import) test,
|
||||
) {
|
||||
bool Function(String import) test, {
|
||||
Set<String>? onlyFiles,
|
||||
}) {
|
||||
final results = <({String file, String import})>[];
|
||||
final dir = Directory(directory);
|
||||
if (!dir.existsSync()) return results;
|
||||
@@ -111,6 +128,7 @@ List<({String file, String import})> _importsMatching(
|
||||
final pattern = RegExp(r'''^\s*import\s+['"]([^'"]+)['"]''', multiLine: true);
|
||||
for (final entity in dir.listSync(recursive: true)) {
|
||||
if (entity is! File || !entity.path.endsWith('.dart')) continue;
|
||||
if (onlyFiles != null && !onlyFiles.contains(entity.path)) continue;
|
||||
final source = entity.readAsStringSync();
|
||||
for (final match in pattern.allMatches(source)) {
|
||||
final import = match.group(1)!;
|
||||
|
||||
Reference in New Issue
Block a user