Skip to content

Commit cd17980

Browse files
committed
Detect build readiness from webdev stdout
Switches WebdevServer from ProcessStartMode.inheritStdio to ProcessStartMode.normal so that webdev stdout can be monitored. Exposes a stdoutLines broadcast stream on WebdevServer; stderr continues to be forwarded to the parent process. ServeCommand listens to stdoutLines, forwarding each line to stdout and watching for build_runner's completion signal ('Built with build_runner' / 'Failed to build with build_runner'). Once detected, it emits '[INFO] Succeeded after N seconds' on its own line. Also adds a test verifying the build-complete signal appears in webdev stdout before the HTTP server becomes responsive.
1 parent 3f16c75 commit cd17980

5 files changed

Lines changed: 208 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## Unreleased
2+
3+
- #67 Detect build readiness from webdev stdout for compatibility with Dart 3 / newer webdev versions.
4+
15
## 0.1.12
26

37
- Update ranges of dependencies so that in Dart 3 we can resolve to analyzer 6, while still working with Dart 2.19. https://github.com/Workiva/webdev_proxy/pull/46

lib/src/serve_command.dart

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ class ServeCommand extends Command<int> {
149149
for (final dir in portsToServeByDir.keys) dir: await findUnusedPort()
150150
};
151151

152+
final startupTimer = Stopwatch()..start();
153+
152154
// Start the underlying `webdev serve` process.
153155
webdevServer = await WebdevServer.start([
154156
if (hostname != 'localhost') '--hostname=$hostname',
@@ -157,6 +159,10 @@ class ServeCommand extends Command<int> {
157159
'$dir:${portsToProxyByDir[dir]}',
158160
]);
159161

162+
// Forward webdev stdout bytes directly so the terminal interprets ANSI
163+
// escape sequences and \r-based line rewrites natively.
164+
webdevServer.stdoutBytes.listen((bytes) => stdout.add(bytes));
165+
160166
// Stop proxies and exit if webdev exits.
161167
unawaited(webdevServer.exitCode.then((code) {
162168
if (!interruptReceived && !proxiesFailed) {
@@ -186,6 +192,30 @@ class ServeCommand extends Command<int> {
186192
}
187193
}
188194

195+
if (!proxiesFailed) {
196+
// Wait for build_runner to report completion via webdev's stdout.
197+
// build_runner always emits one of these lines when the initial build
198+
// finishes, regardless of whether -v/verbose is set.
199+
const buildCompleteStrings = [
200+
'Built with build_runner',
201+
'Failed to build with build_runner',
202+
];
203+
await Future.any([
204+
webdevServer.stdoutLines.firstWhere(
205+
(line) => buildCompleteStrings.any(line.contains),
206+
),
207+
exitCodeCompleter.future,
208+
]);
209+
210+
if (!exitCodeCompleter.isCompleted) {
211+
final elapsedSeconds =
212+
(startupTimer.elapsedMilliseconds / 1000).toStringAsFixed(1);
213+
stdout.writeln('[INFO] Succeeded after $elapsedSeconds seconds');
214+
}
215+
}
216+
217+
startupTimer.stop();
218+
189219
return exitCodeCompleter.future;
190220
}
191221
}

lib/src/webdev_server.dart

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
// limitations under the License.
1414

1515
import 'dart:async';
16+
import 'dart:convert';
1617
import 'dart:io';
1718

1819
import 'package:webdev_proxy/src/logging.dart';
@@ -22,7 +23,20 @@ class WebdevServer {
2223
/// The `webdev serve ...` process.
2324
final Process _process;
2425

25-
WebdevServer._(this._process);
26+
/// A broadcast stream of raw stdout bytes from the `webdev serve` process.
27+
///
28+
/// Pipe directly to [stdout] to preserve webdev's native terminal output,
29+
/// including ANSI escape sequences and carriage-return-based line rewrites.
30+
final Stream<List<int>> stdoutBytes;
31+
32+
/// A broadcast stream of lines from the `webdev serve` process stdout.
33+
///
34+
/// Useful for detecting build lifecycle events such as
35+
/// `'Built with build_runner'` (success) or
36+
/// `'Failed to build with build_runner'` (failure).
37+
final Stream<String> stdoutLines;
38+
39+
WebdevServer._(this._process, this.stdoutBytes, this.stdoutLines);
2640

2741
/// Returns a Future which completes with the exit code of the underlying
2842
/// `webdev serve` process.
@@ -37,15 +51,41 @@ class WebdevServer {
3751

3852
/// Starts a `webdev serve` process with the given [args] and returns a
3953
/// [WebdevServer] abstraction over said process.
40-
static Future<WebdevServer> start(List<String> args,
41-
{ProcessStartMode mode = ProcessStartMode.inheritStdio}) async {
42-
final webdevArgs = ['pub', 'global', 'run', 'webdev', 'serve', ...args];
54+
static Future<WebdevServer> start(List<String> args) async {
55+
final webdevArgs = [
56+
'pub',
57+
'global',
58+
'run',
59+
'webdev',
60+
'serve',
61+
...args,
62+
];
4363
log.fine('Running `dart ${webdevArgs.join(' ')}');
4464
final process = await Process.start(
4565
'dart',
4666
webdevArgs,
47-
mode: mode,
67+
mode: ProcessStartMode.normal,
4868
);
49-
return WebdevServer._(process);
69+
70+
// Forward stderr so error messages remain visible.
71+
process.stderr.listen((data) => stderr.add(data));
72+
73+
// Fork stdout at the byte level into a broadcast stream. The listen() call
74+
// here is essential: it drains the process stdout pipe (preventing
75+
// backpressure deadlock) regardless of whether anyone subscribes.
76+
final bytesController = StreamController<List<int>>.broadcast();
77+
process.stdout.listen(bytesController.add,
78+
onDone: bytesController.close, cancelOnError: false);
79+
80+
// Derive a text-line stream from the same bytes for signal detection.
81+
final linesController = StreamController<String>.broadcast();
82+
bytesController.stream
83+
.transform(utf8.decoder)
84+
.transform(const LineSplitter())
85+
.listen(linesController.add,
86+
onDone: linesController.close, cancelOnError: false);
87+
88+
return WebdevServer._(
89+
process, bytesController.stream, linesController.stream);
5090
}
5191
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright 2019 Workiva Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
@TestOn('vm')
16+
import 'dart:async';
17+
import 'dart:convert';
18+
import 'dart:io';
19+
20+
import 'package:http/http.dart' as http;
21+
import 'package:test/test.dart';
22+
import 'package:webdev_proxy/src/port_utils.dart';
23+
import 'package:webdev_proxy/src/webdev_proc_utils.dart';
24+
import 'package:webdev_proxy/src/webdev_server.dart';
25+
26+
import 'util.dart';
27+
28+
// Matches the build-complete patterns that build_runner emits to webdev stdout.
29+
final _buildCompletePattern =
30+
RegExp(r'Built with build_runner|Failed to build with build_runner');
31+
32+
// Strips ANSI escape sequences so output is readable in test logs.
33+
String _stripAnsi(String s) =>
34+
s.replaceAll(RegExp(r'\x1b\[[0-9;]*[a-zA-Z]'), '');
35+
36+
void main() {
37+
setUpAll(() async {
38+
await activateWebdev(webdevCompatibility.toString());
39+
});
40+
41+
group('WebdevServer', () {
42+
late WebdevServer webdevServer;
43+
44+
tearDown(() async {
45+
await webdevServer.close();
46+
});
47+
48+
test('stdoutLines emits build-complete signal before HTTP server responds',
49+
() async {
50+
final port = await findUnusedPort();
51+
webdevServer = await WebdevServer.start(['test:$port']);
52+
53+
final allLines = <String>[];
54+
webdevServer.stdoutLines.listen((line) => allLines.add(line));
55+
56+
String? matchedLine;
57+
try {
58+
matchedLine = await webdevServer.stdoutLines
59+
.firstWhere(_buildCompletePattern.hasMatch)
60+
.timeout(const Duration(seconds: 90));
61+
} on TimeoutException {
62+
fail('Timed out waiting for build-complete signal.\n'
63+
'Lines seen:\n'
64+
'${allLines.map(_stripAnsi).map((l) => ' $l').join('\n')}');
65+
} on StateError {
66+
fail('Build-complete signal never appeared — webdev exited early.\n'
67+
'Lines seen:\n'
68+
'${allLines.map(_stripAnsi).map((l) => ' $l').join('\n')}');
69+
}
70+
71+
expect(_stripAnsi(matchedLine), matches(_buildCompletePattern));
72+
73+
// Verify the server is also HTTP-responsive at this point.
74+
final response = await http
75+
.get(Uri.parse('http://localhost:$port/'))
76+
.timeout(const Duration(seconds: 10));
77+
expect(response.statusCode, isNot(equals(0)));
78+
});
79+
});
80+
81+
group('ServeCommand', () {
82+
late Process proxyProcess;
83+
84+
tearDown(() async {
85+
proxyProcess.kill();
86+
await proxyProcess.exitCode;
87+
});
88+
89+
test('outputs "Succeeded after" once the initial build completes',
90+
() async {
91+
final port = await findUnusedPort();
92+
proxyProcess = await Process.start(
93+
'dart',
94+
['bin/webdev_proxy.dart', 'serve', '--', 'test:$port'],
95+
mode: ProcessStartMode.normal,
96+
);
97+
98+
final stdoutLines = proxyProcess.stdout
99+
.transform(utf8.decoder)
100+
.transform(const LineSplitter())
101+
.asBroadcastStream();
102+
103+
// Drain stderr so the process doesn't block.
104+
proxyProcess.stderr.drain<void>();
105+
106+
final allLines = <String>[];
107+
stdoutLines.listen((line) => allLines.add(line));
108+
109+
String? matchedLine;
110+
try {
111+
matchedLine = await stdoutLines
112+
.firstWhere((line) => line.contains('Succeeded after'))
113+
.timeout(const Duration(seconds: 90));
114+
} on TimeoutException {
115+
fail('Timed out waiting for "Succeeded after" in proxy output.\n'
116+
'Lines seen:\n'
117+
'${allLines.map(_stripAnsi).map((l) => ' $l').join('\n')}');
118+
} on StateError {
119+
fail('"Succeeded after" never appeared — proxy exited early.\n'
120+
'Lines seen:\n'
121+
'${allLines.map(_stripAnsi).map((l) => ' $l').join('\n')}');
122+
}
123+
124+
expect(_stripAnsi(matchedLine), contains('Succeeded after'));
125+
});
126+
});
127+
}

test/webdev_server_test.dart

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,6 @@
1313
// limitations under the License.
1414

1515
@TestOn('vm')
16-
import 'dart:io';
17-
1816
import 'package:http/http.dart' as http;
1917
import 'package:test/test.dart';
2018
import 'package:webdev_proxy/src/port_utils.dart';
@@ -36,8 +34,7 @@ void main() async {
3634

3735
test('Serves a directory', () async {
3836
final port = await findUnusedPort();
39-
webdevServer =
40-
await WebdevServer.start(['test:$port'], mode: ProcessStartMode.normal);
37+
webdevServer = await WebdevServer.start(['test:$port']);
4138

4239
// We don't have a good way of knowing when the `webdev serve` process has
4340
// started listening on the port, so we send a request periodically until it

0 commit comments

Comments
 (0)