Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,18 @@ 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:
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 }}
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
30 changes: 30 additions & 0 deletions lib/src/serve_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,8 @@ class ServeCommand extends Command<int> {
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',
Expand All @@ -157,6 +159,10 @@ class ServeCommand extends Command<int> {
'$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) {
Expand Down Expand Up @@ -186,6 +192,30 @@ class ServeCommand extends Command<int> {
}
}

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;
}
}
52 changes: 46 additions & 6 deletions lib/src/webdev_server.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
// limitations under the License.

import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:webdev_proxy/src/logging.dart';
Expand All @@ -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<List<int>> 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<String> stdoutLines;

WebdevServer._(this._process, this.stdoutBytes, this.stdoutLines);

/// Returns a Future which completes with the exit code of the underlying
/// `webdev serve` process.
Expand All @@ -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<WebdevServer> start(List<String> args,
{ProcessStartMode mode = ProcessStartMode.inheritStdio}) async {
final webdevArgs = ['pub', 'global', 'run', 'webdev', 'serve', ...args];
static Future<WebdevServer> start(List<String> 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<List<int>>.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<String>.broadcast();
bytesController.stream
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(linesController.add,
onDone: linesController.close, cancelOnError: false);

return WebdevServer._(
process, bytesController.stream, linesController.stream);
}
}
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
127 changes: 127 additions & 0 deletions test/webdev_ready_detection_test.dart
Original file line number Diff line number Diff line change
@@ -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 = <String>[];
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<void>();

final allLines = <String>[];
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'));
});
});
}
5 changes: 1 addition & 4 deletions test/webdev_server_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down
Loading