Skip to content

Commit d80db8c

Browse files
authored
ci: Enhance test outputs for CI (#3904)
Enhance test outputs for CI <img width="1082" height="1005" alt="image" src="https://github.com/user-attachments/assets/f4f9553e-da5f-4cc2-9f38-aef7103e152a" />
1 parent 57b54d8 commit d80db8c

2 files changed

Lines changed: 212 additions & 1 deletion

File tree

pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ melos:
156156
description: Checks whether there are any broken links in the docs.
157157

158158
test:select:
159-
run: melos exec -c 1 -- flutter test
159+
run: melos exec -c 1 -- flutter test --reporter=json 2>&1 | dart run "$MELOS_ROOT_PATH/scripts/test_formatter.dart"
160160
packageFilters:
161161
dirExists: test
162162
description: Run `flutter test` for selected packages.

scripts/test_formatter.dart

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import 'dart:convert';
2+
import 'dart:io';
3+
4+
void main() {
5+
final parser = _OutputParser();
6+
7+
stdin
8+
.transform(utf8.decoder)
9+
.transform(const LineSplitter())
10+
.listen(
11+
parser.processLine,
12+
onDone: () {
13+
parser.flush();
14+
exitCode = parser.success ? 0 : 1;
15+
},
16+
);
17+
}
18+
19+
class _OutputParser {
20+
final _packages = [_PackageParser()];
21+
22+
int get failed => _packages.sumBy((package) => package.failed);
23+
int get passed => _packages.sumBy((package) => package.passed);
24+
bool get success => failed == 0;
25+
26+
void processLine(String line) {
27+
if (!line.startsWith('{')) {
28+
_packages.last.processPlainLine(line);
29+
return;
30+
}
31+
32+
try {
33+
final event = jsonDecode(line) as Map<String, dynamic>;
34+
35+
if (!_packages.last.processJsonEvent(event)) {
36+
_OutputWriter.info(line);
37+
}
38+
39+
if (event['type'] == 'done') {
40+
_packages.add(_PackageParser());
41+
}
42+
} on FormatException {
43+
_OutputWriter.info(line);
44+
return;
45+
}
46+
}
47+
48+
void flush() {
49+
_packages.last.flush();
50+
_OutputWriter.info('');
51+
final logLevel = success ? _LogLevel.success : _LogLevel.failure;
52+
_OutputWriter.log(logLevel, 'Total: $passed passed, $failed failed');
53+
}
54+
}
55+
56+
class _PackageParser {
57+
static final _dependencyLinePattern = RegExp(
58+
r'^\s+\S+ \d+\.\S+.*available\)$',
59+
);
60+
61+
var _suppressedDependencyCount = 0;
62+
63+
final _activeTests = <int, _TestEntry>{};
64+
var passed = 0;
65+
var failed = 0;
66+
67+
void processPlainLine(String line) {
68+
if (_dependencyLinePattern.hasMatch(line)) {
69+
_suppressedDependencyCount++;
70+
return;
71+
}
72+
73+
flush();
74+
_OutputWriter.info(line);
75+
}
76+
77+
void flush() {
78+
if (_suppressedDependencyCount > 0) {
79+
_flushDependencyBlock();
80+
}
81+
}
82+
83+
bool processJsonEvent(Map<String, dynamic> event) {
84+
final type = event['type'] as String?;
85+
86+
switch (type) {
87+
case 'start':
88+
case 'suite':
89+
case 'allSuites':
90+
case 'group':
91+
break;
92+
93+
case 'testStart':
94+
final test = event['test'] as Map<String, dynamic>?;
95+
final id = test?['id'] as int?;
96+
final name = test?['name'] as String?;
97+
if (id == null || name == null) {
98+
return false;
99+
}
100+
_activeTests[id] = (name: name, output: StringBuffer());
101+
102+
case 'print':
103+
final id = event['testID'] as int?;
104+
final test = _activeTests[id];
105+
if (test == null) {
106+
return false;
107+
}
108+
test.output.writeln(event['message'] ?? '');
109+
110+
case 'error':
111+
final id = event['testID'] as int?;
112+
final test = _activeTests[id];
113+
if (test == null) {
114+
return false;
115+
}
116+
test.output.writeln(event['error'] ?? '');
117+
final stack = event['stackTrace'] as String?;
118+
if (stack != null && stack.isNotEmpty) {
119+
test.output.writeln(stack);
120+
}
121+
122+
case 'testDone':
123+
final id = event['testID'] as int?;
124+
if (id == null) {
125+
return false;
126+
}
127+
if (event['hidden'] == true) {
128+
_activeTests.remove(id);
129+
break;
130+
}
131+
final test = _activeTests.remove(id);
132+
final result = event['result'] as String?;
133+
if (result == 'success') {
134+
passed++;
135+
} else {
136+
failed++;
137+
_OutputWriter.info('');
138+
_OutputWriter.failure('━━━ FAIL: ${test?.name ?? 'unknown test'}');
139+
final output = test?.output;
140+
if (output != null && output.isNotEmpty) {
141+
_OutputWriter.failure(output.toString());
142+
} else {
143+
_OutputWriter.failure('━━━ No output captured for this test.');
144+
}
145+
}
146+
147+
case 'done':
148+
final logLevel = failed > 0 ? _LogLevel.failure : _LogLevel.success;
149+
_OutputWriter.log(logLevel, '$passed passed, $failed failed');
150+
151+
default:
152+
// unknown - pass through
153+
return false;
154+
}
155+
156+
return true;
157+
}
158+
159+
void _flushDependencyBlock() {
160+
_OutputWriter.info(
161+
' ($_suppressedDependencyCount packages have newer versions available)',
162+
);
163+
_suppressedDependencyCount = 0;
164+
}
165+
}
166+
167+
extension _SumBy<T> on List<T> {
168+
int sumBy(int Function(T) selector) {
169+
return fold(0, (sum, element) => sum + selector(element));
170+
}
171+
}
172+
173+
typedef _TestEntry = ({String name, StringBuffer output});
174+
175+
enum _LogLevel { info, success, failure }
176+
177+
class _OutputWriter {
178+
_OutputWriter._();
179+
180+
static const _ansiRed = '\x1B[31m';
181+
static const _ansiGreen = '\x1B[32m';
182+
static const _ansiReset = '\x1B[0m';
183+
184+
static String _colored(String message, String color) {
185+
return '$color$message$_ansiReset';
186+
}
187+
188+
static void info(String message) {
189+
stdout.writeln(message);
190+
}
191+
192+
static void success(String message) {
193+
stdout.writeln(_colored(message, _ansiGreen));
194+
}
195+
196+
static void failure(String message) {
197+
stderr.writeln(_colored(message, _ansiRed));
198+
}
199+
200+
static void Function(String) logger(_LogLevel level) {
201+
return switch (level) {
202+
_LogLevel.info => info,
203+
_LogLevel.success => success,
204+
_LogLevel.failure => failure,
205+
};
206+
}
207+
208+
static void log(_LogLevel level, String message) {
209+
logger(level)(message);
210+
}
211+
}

0 commit comments

Comments
 (0)