From f45658ecf86ee48fcc0e20a2549bb3e9c374a4c4 Mon Sep 17 00:00:00 2001 From: evanweible-wf Date: Wed, 1 Apr 2026 10:28:46 -0600 Subject: [PATCH 1/3] 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. --- CHANGELOG.md | 4 + lib/src/serve_command.dart | 30 ++++++ lib/src/webdev_server.dart | 52 +++++++++-- pubspec.yaml | 2 +- test/webdev_ready_detection_test.dart | 127 ++++++++++++++++++++++++++ test/webdev_server_test.dart | 5 +- 6 files changed, 209 insertions(+), 11 deletions(-) create mode 100644 test/webdev_ready_detection_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f4e0dd..6f56295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased + +- #67 Detect build readiness from webdev stdout for compatibility with Dart 3 / newer webdev versions. + ## 0.1.12 - 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 diff --git a/lib/src/serve_command.dart b/lib/src/serve_command.dart index 5749dba..5265231 100644 --- a/lib/src/serve_command.dart +++ b/lib/src/serve_command.dart @@ -149,6 +149,8 @@ class ServeCommand extends Command { for (final dir in portsToServeByDir.keys) dir: await findUnusedPort() }; + final startupTimer = Stopwatch()..start(); + // Start the underlying `webdev serve` process. webdevServer = await WebdevServer.start([ if (hostname != 'localhost') '--hostname=$hostname', @@ -157,6 +159,10 @@ class ServeCommand extends Command { '$dir:${portsToProxyByDir[dir]}', ]); + // Forward webdev stdout bytes directly so the terminal interprets ANSI + // escape sequences and \r-based line rewrites natively. + webdevServer.stdoutBytes.listen((bytes) => stdout.add(bytes)); + // Stop proxies and exit if webdev exits. unawaited(webdevServer.exitCode.then((code) { if (!interruptReceived && !proxiesFailed) { @@ -186,6 +192,30 @@ class ServeCommand extends Command { } } + if (!proxiesFailed) { + // Wait for build_runner to report completion via webdev's stdout. + // build_runner always emits one of these lines when the initial build + // finishes, regardless of whether -v/verbose is set. + const buildCompleteStrings = [ + 'Built with build_runner', + 'Failed to build with build_runner', + ]; + await Future.any([ + webdevServer.stdoutLines.firstWhere( + (line) => buildCompleteStrings.any(line.contains), + ), + exitCodeCompleter.future, + ]); + + if (!exitCodeCompleter.isCompleted) { + final elapsedSeconds = + (startupTimer.elapsedMilliseconds / 1000).toStringAsFixed(1); + stdout.writeln('[INFO] Succeeded after $elapsedSeconds seconds'); + } + } + + startupTimer.stop(); + return exitCodeCompleter.future; } } diff --git a/lib/src/webdev_server.dart b/lib/src/webdev_server.dart index 757cedf..cb60245 100644 --- a/lib/src/webdev_server.dart +++ b/lib/src/webdev_server.dart @@ -13,6 +13,7 @@ // limitations under the License. import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:webdev_proxy/src/logging.dart'; @@ -22,7 +23,20 @@ class WebdevServer { /// The `webdev serve ...` process. final Process _process; - WebdevServer._(this._process); + /// A broadcast stream of raw stdout bytes from the `webdev serve` process. + /// + /// Pipe directly to [stdout] to preserve webdev's native terminal output, + /// including ANSI escape sequences and carriage-return-based line rewrites. + final Stream> stdoutBytes; + + /// A broadcast stream of lines from the `webdev serve` process stdout. + /// + /// Useful for detecting build lifecycle events such as + /// `'Built with build_runner'` (success) or + /// `'Failed to build with build_runner'` (failure). + final Stream stdoutLines; + + WebdevServer._(this._process, this.stdoutBytes, this.stdoutLines); /// Returns a Future which completes with the exit code of the underlying /// `webdev serve` process. @@ -37,15 +51,41 @@ class WebdevServer { /// Starts a `webdev serve` process with the given [args] and returns a /// [WebdevServer] abstraction over said process. - static Future start(List args, - {ProcessStartMode mode = ProcessStartMode.inheritStdio}) async { - final webdevArgs = ['pub', 'global', 'run', 'webdev', 'serve', ...args]; + static Future start(List args) async { + final webdevArgs = [ + 'pub', + 'global', + 'run', + 'webdev', + 'serve', + ...args, + ]; log.fine('Running `dart ${webdevArgs.join(' ')}'); final process = await Process.start( 'dart', webdevArgs, - mode: mode, + mode: ProcessStartMode.normal, ); - return WebdevServer._(process); + + // Forward stderr so error messages remain visible. + process.stderr.listen((data) => stderr.add(data)); + + // Fork stdout at the byte level into a broadcast stream. The listen() call + // here is essential: it drains the process stdout pipe (preventing + // backpressure deadlock) regardless of whether anyone subscribes. + final bytesController = StreamController>.broadcast(); + process.stdout.listen(bytesController.add, + onDone: bytesController.close, cancelOnError: false); + + // Derive a text-line stream from the same bytes for signal detection. + final linesController = StreamController.broadcast(); + bytesController.stream + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen(linesController.add, + onDone: linesController.close, cancelOnError: false); + + return WebdevServer._( + process, bytesController.stream, linesController.stream); } } diff --git a/pubspec.yaml b/pubspec.yaml index e83eaec..1f6c43b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,7 +8,7 @@ description: > routing by rewriting 404s to the root index. environment: - sdk: '>=2.19.0 <3.0.0' + sdk: '>=3.0.0 <4.0.0' dependencies: args: ^2.3.1 diff --git a/test/webdev_ready_detection_test.dart b/test/webdev_ready_detection_test.dart new file mode 100644 index 0000000..aab1e73 --- /dev/null +++ b/test/webdev_ready_detection_test.dart @@ -0,0 +1,127 @@ +// Copyright 2019 Workiva Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@TestOn('vm') +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:test/test.dart'; +import 'package:webdev_proxy/src/port_utils.dart'; +import 'package:webdev_proxy/src/webdev_proc_utils.dart'; +import 'package:webdev_proxy/src/webdev_server.dart'; + +import 'util.dart'; + +// Matches the build-complete patterns that build_runner emits to webdev stdout. +final _buildCompletePattern = + RegExp(r'Built with build_runner|Failed to build with build_runner'); + +// Strips ANSI escape sequences so output is readable in test logs. +String _stripAnsi(String s) => + s.replaceAll(RegExp(r'\x1b\[[0-9;]*[a-zA-Z]'), ''); + +void main() { + setUpAll(() async { + await activateWebdev(webdevCompatibility.toString()); + }); + + group('WebdevServer', () { + late WebdevServer webdevServer; + + tearDown(() async { + await webdevServer.close(); + }); + + test('stdoutLines emits build-complete signal before HTTP server responds', + () async { + final port = await findUnusedPort(); + webdevServer = await WebdevServer.start(['test:$port']); + + final allLines = []; + webdevServer.stdoutLines.listen((line) => allLines.add(line)); + + String? matchedLine; + try { + matchedLine = await webdevServer.stdoutLines + .firstWhere(_buildCompletePattern.hasMatch) + .timeout(const Duration(seconds: 90)); + } on TimeoutException { + fail('Timed out waiting for build-complete signal.\n' + 'Lines seen:\n' + '${allLines.map(_stripAnsi).map((l) => ' $l').join('\n')}'); + } on StateError { + fail('Build-complete signal never appeared — webdev exited early.\n' + 'Lines seen:\n' + '${allLines.map(_stripAnsi).map((l) => ' $l').join('\n')}'); + } + + expect(_stripAnsi(matchedLine), matches(_buildCompletePattern)); + + // Verify the server is also HTTP-responsive at this point. + final response = await http + .get(Uri.parse('http://localhost:$port/')) + .timeout(const Duration(seconds: 10)); + expect(response.statusCode, isNot(equals(0))); + }); + }); + + group('ServeCommand', () { + late Process proxyProcess; + + tearDown(() async { + proxyProcess.kill(); + await proxyProcess.exitCode; + }); + + test('outputs "Succeeded after" once the initial build completes', + () async { + final port = await findUnusedPort(); + proxyProcess = await Process.start( + 'dart', + ['bin/webdev_proxy.dart', 'serve', '--', 'test:$port'], + mode: ProcessStartMode.normal, + ); + + final stdoutLines = proxyProcess.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .asBroadcastStream(); + + // Drain stderr so the process doesn't block. + proxyProcess.stderr.drain(); + + final allLines = []; + stdoutLines.listen((line) => allLines.add(line)); + + String? matchedLine; + try { + matchedLine = await stdoutLines + .firstWhere((line) => line.contains('Succeeded after')) + .timeout(const Duration(seconds: 90)); + } on TimeoutException { + fail('Timed out waiting for "Succeeded after" in proxy output.\n' + 'Lines seen:\n' + '${allLines.map(_stripAnsi).map((l) => ' $l').join('\n')}'); + } on StateError { + fail('"Succeeded after" never appeared — proxy exited early.\n' + 'Lines seen:\n' + '${allLines.map(_stripAnsi).map((l) => ' $l').join('\n')}'); + } + + expect(_stripAnsi(matchedLine), contains('Succeeded after')); + }); + }); +} diff --git a/test/webdev_server_test.dart b/test/webdev_server_test.dart index e6c9a29..b9fd1d8 100644 --- a/test/webdev_server_test.dart +++ b/test/webdev_server_test.dart @@ -13,8 +13,6 @@ // limitations under the License. @TestOn('vm') -import 'dart:io'; - import 'package:http/http.dart' as http; import 'package:test/test.dart'; import 'package:webdev_proxy/src/port_utils.dart'; @@ -36,8 +34,7 @@ void main() async { test('Serves a directory', () async { final port = await findUnusedPort(); - webdevServer = - await WebdevServer.start(['test:$port'], mode: ProcessStartMode.normal); + webdevServer = await WebdevServer.start(['test:$port']); // We don't have a good way of knowing when the `webdev serve` process has // started listening on the port, so we send a request periodically until it From 74bdbd807aaf988619a045661cbe1ae991b96af0 Mon Sep 17 00:00:00 2001 From: evanweible-wf Date: Wed, 1 Apr 2026 14:54:26 -0600 Subject: [PATCH 2/3] Remove Dart 2.x from CI matrix SDK constraint is now >=3.0.0 so there is no need to test against 2.19.6. --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f54185a..68599e6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -18,7 +18,7 @@ jobs: test-unit: strategy: matrix: - sdk: [ 2.19.6, stable ] + sdk: [ stable ] uses: Workiva/gha-dart-oss/.github/workflows/test-unit.yaml@v0.1.14 with: sdk: ${{ matrix.sdk }} From a34a895bc35a1579fc220fd39d3ff0e76daa03bc Mon Sep 17 00:00:00 2001 From: evanweible-wf Date: Wed, 1 Apr 2026 14:56:43 -0600 Subject: [PATCH 3/3] Pass sdk: stable to build and checks reusable workflows Both workflows default to 2.19.6 if sdk is not specified; explicitly pass stable to keep CI on Dart 3. --- .github/workflows/ci.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 68599e6..9213199 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,9 +11,13 @@ on: jobs: build: uses: Workiva/gha-dart-oss/.github/workflows/build.yaml@v0.1.14 + with: + sdk: stable checks: uses: Workiva/gha-dart-oss/.github/workflows/checks.yaml@v0.1.14 + with: + sdk: stable test-unit: strategy: