[ Widget Preview ] Add --machine mode (#173654)

Currently only outputs a single event with information about where the
widget preview environment is served from:


`[{"event":"widget_preview.started","params":{"url":"http://localhost:61383"}}]`

Fixes https://github.com/flutter/flutter/issues/173545
This commit is contained in:
Ben Konyi 2025-08-13 15:08:53 -04:00 committed by GitHub
parent 9e99953a4e
commit f8e8a8dce9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 276 additions and 7 deletions

View File

@ -88,10 +88,7 @@ Future<void> main(List<String> args) async {
final bool daemon = args.contains('daemon');
final bool runMachine =
(args.contains('--machine') && args.contains('run')) ||
(args.contains('--machine') && args.contains('attach')) ||
// `flutter widget-preview start` starts an application that requires a logger
// to be setup for machine mode.
(args.contains('widget-preview') && args.contains('start'));
(args.contains('--machine') && args.contains('attach'));
// Cache.flutterRoot must be set early because other features use it (e.g.
// enginePath's initializer uses it). This can only work with the real

View File

@ -16,9 +16,11 @@ import '../base/logger.dart';
import '../base/os.dart';
import '../base/platform.dart';
import '../base/process.dart';
import '../base/terminal.dart';
import '../build_info.dart';
import '../bundle.dart' as bundle;
import '../cache.dart';
import '../convert.dart';
import '../device.dart';
import '../globals.dart' as globals;
import '../isolated/resident_web_runner.dart';
@ -116,7 +118,7 @@ abstract base class WidgetPreviewSubCommandBase extends FlutterCommand {
final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with CreateBase {
WidgetPreviewStartCommand({
this.verbose = false,
required this.logger,
required Logger logger,
required this.fs,
required this.projectFactory,
required this.cache,
@ -126,11 +128,12 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C
required this.processManager,
required this.artifacts,
@visibleForTesting WidgetPreviewDtdServices? dtdServicesOverride,
}) {
}) : logger = WidgetPreviewMachineAwareLogger(logger) {
if (dtdServicesOverride != null) {
_dtdService = dtdServicesOverride;
}
addPubOptions();
addMachineOutputFlag(verboseHelp: verbose);
argParser
..addFlag(
kWebServer,
@ -189,7 +192,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C
final FileSystem fs;
@override
final Logger logger;
final WidgetPreviewMachineAwareLogger logger;
@override
final FlutterProjectFactory projectFactory;
@ -255,6 +258,9 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C
? fs.directory(customPreviewScaffoldOutput)
: rootProject.widgetPreviewScaffold;
final bool machine = boolArg(FlutterGlobalOptions.kMachineFlag);
logger.machine = machine;
// Check to see if a preview scaffold has already been generated. If not,
// generate one.
final bool generateScaffoldProject =
@ -444,6 +450,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C
);
unawaited(_widgetPreviewApp!.run(appStartedCompleter: appStarted));
await appStarted.future;
logger.sendEvent('started', {'url': flutterDevice.devFS!.baseUri.toString()});
}
} on Exception catch (error) {
throwToolExit(error.toString());
@ -491,3 +498,145 @@ final class WidgetPreviewCleanCommand extends WidgetPreviewSubCommandBase {
return FlutterCommandResult.success();
}
}
/// A custom logger for the widget-preview commands that disables non-event output to stdio when
/// machine mode is enabled.
final class WidgetPreviewMachineAwareLogger extends DelegatingLogger {
WidgetPreviewMachineAwareLogger(super.delegate);
var machine = false;
@override
void printError(
String message, {
StackTrace? stackTrace,
bool? emphasis,
TerminalColor? color,
int? indent,
int? hangingIndent,
bool? wrap,
}) {
if (machine) {
return;
}
super.printError(
message,
stackTrace: stackTrace,
emphasis: emphasis,
color: color,
indent: indent,
hangingIndent: hangingIndent,
wrap: wrap,
);
}
@override
void printWarning(
String message, {
bool? emphasis,
TerminalColor? color,
int? indent,
int? hangingIndent,
bool? wrap,
bool fatal = true,
}) {
if (machine) {
return;
}
super.printWarning(
message,
emphasis: emphasis,
color: color,
indent: indent,
hangingIndent: hangingIndent,
wrap: wrap,
fatal: fatal,
);
}
@override
void printStatus(
String message, {
bool? emphasis,
TerminalColor? color,
bool? newline,
int? indent,
int? hangingIndent,
bool? wrap,
}) {
if (machine) {
return;
}
super.printStatus(
message,
emphasis: emphasis,
color: color,
newline: newline,
indent: indent,
hangingIndent: hangingIndent,
wrap: wrap,
);
}
@override
void printBox(String message, {String? title}) {
if (machine) {
return;
}
super.printBox(message, title: title);
}
@override
void printTrace(String message) {
if (machine) {
return;
}
super.printTrace(message);
}
@override
void sendEvent(String name, [Map<String, dynamic>? args]) {
if (!machine) {
return;
}
super.printStatus(
json.encode([
{'event': 'widget_preview.$name', 'params': ?args},
]),
);
}
@override
Status startProgress(
String message, {
String? progressId,
int progressIndicatorPadding = kDefaultStatusPadding,
}) {
if (machine) {
return SilentStatus(stopwatch: Stopwatch());
}
return super.startProgress(
message,
progressId: progressId,
progressIndicatorPadding: progressIndicatorPadding,
);
}
@override
Status startSpinner({
VoidCallback? onFinish,
Duration? timeout,
SlowWarningCallback? slowWarningCallback,
TerminalColor? warningColor,
}) {
if (machine) {
return SilentStatus(stopwatch: Stopwatch());
}
return super.startSpinner(
onFinish: onFinish,
timeout: timeout,
slowWarningCallback: slowWarningCallback,
warningColor: warningColor,
);
}
}

View File

@ -0,0 +1,123 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:async';
import 'dart:convert';
import 'package:file/file.dart';
import 'package:flutter_tools/src/base/io.dart';
import 'package:flutter_tools/src/commands/widget_preview.dart';
import 'package:flutter_tools/src/widget_preview/dtd_services.dart';
import 'package:http/http.dart';
import 'package:process/process.dart';
import '../src/common.dart';
import 'test_data/basic_project.dart';
import 'test_utils.dart';
typedef ExpectedEvent = ({String event, FutureOr<void> Function(Map<String, Object?>)? validator});
final launchEvents = <ExpectedEvent>[
(
event: 'widget_preview.started',
validator: (Map<String, Object?> params) async {
if (params case {'uri': final String uri}) {
try {
final Response response = await get(Uri.parse(uri));
expect(response.statusCode, HttpStatus.ok, reason: 'Failed to retrieve widget previewer');
} catch (e) {
fail('Failed to access widget previewer: $e');
}
}
},
),
];
void main() {
late Directory tempDir;
Process? process;
DtdLauncher? dtdLauncher;
final project = BasicProject();
const ProcessManager processManager = LocalProcessManager();
setUp(() async {
tempDir = createResolvedTempDirectorySync('widget_preview_test.');
await project.setUpIn(tempDir);
});
tearDown(() async {
process?.kill();
process = null;
await dtdLauncher?.dispose();
dtdLauncher = null;
tryToDelete(tempDir);
});
Future<void> runWidgetPreviewMachineMode({
required List<ExpectedEvent> expectedEvents,
bool useWebServer = false,
}) async {
expect(expectedEvents, isNotEmpty);
process = await processManager.start(<String>[
flutterBin,
'widget-preview',
'start',
'--machine',
'--${WidgetPreviewStartCommand.kHeadless}',
if (useWebServer) '--${WidgetPreviewStartCommand.kWebServer}',
], workingDirectory: tempDir.path);
final completer = Completer<void>();
var nextExpectationIndex = 0;
process!.stdout.transform(utf8.decoder).transform(const LineSplitter()).listen((
String message,
) async {
printOnFailure('STDOUT: $message');
if (completer.isCompleted) {
return;
}
try {
final Object? event = json.decode(message);
if (event case [final Map<String, Object?> eventObject]) {
final ExpectedEvent expectation = expectedEvents[nextExpectationIndex];
if (expectation.event == eventObject['event']) {
await expectation.validator?.call(eventObject);
++nextExpectationIndex;
}
}
if (nextExpectationIndex == expectedEvents.length) {
completer.complete();
}
} on FormatException {
// Do nothing.
}
});
process!.stderr.transform(utf8.decoder).transform(const LineSplitter()).listen((String msg) {
printOnFailure('STDERR: $msg');
});
unawaited(
process!.exitCode.then((int exitCode) {
if (completer.isCompleted) {
return;
}
completer.completeError(
TestFailure('The widget previewer exited unexpectedly (exit code: $exitCode)'),
);
}),
);
await completer.future;
}
group('flutter widget-preview start --machine', () {
testWithoutContext('launches in browser', () async {
await runWidgetPreviewMachineMode(expectedEvents: launchEvents);
});
testWithoutContext('launches web server', () async {
await runWidgetPreviewMachineMode(expectedEvents: launchEvents, useWebServer: true);
});
});
}