From f8e8a8dce95f735974b664a37c19ff20165770fe Mon Sep 17 00:00:00 2001 From: Ben Konyi Date: Wed, 13 Aug 2025 15:08:53 -0400 Subject: [PATCH] [ 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 --- packages/flutter_tools/lib/executable.dart | 5 +- .../lib/src/commands/widget_preview.dart | 155 +++++++++++++++++- .../widget_preview_machine_test.dart | 123 ++++++++++++++ 3 files changed, 276 insertions(+), 7 deletions(-) create mode 100644 packages/flutter_tools/test/integration.shard/widget_preview_machine_test.dart diff --git a/packages/flutter_tools/lib/executable.dart b/packages/flutter_tools/lib/executable.dart index 76a4aaba2f2..8b4e107ee4f 100644 --- a/packages/flutter_tools/lib/executable.dart +++ b/packages/flutter_tools/lib/executable.dart @@ -88,10 +88,7 @@ Future main(List 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 diff --git a/packages/flutter_tools/lib/src/commands/widget_preview.dart b/packages/flutter_tools/lib/src/commands/widget_preview.dart index 32854a03b03..ddf9e948234 100644 --- a/packages/flutter_tools/lib/src/commands/widget_preview.dart +++ b/packages/flutter_tools/lib/src/commands/widget_preview.dart @@ -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? 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, + ); + } +} diff --git a/packages/flutter_tools/test/integration.shard/widget_preview_machine_test.dart b/packages/flutter_tools/test/integration.shard/widget_preview_machine_test.dart new file mode 100644 index 00000000000..744981c6365 --- /dev/null +++ b/packages/flutter_tools/test/integration.shard/widget_preview_machine_test.dart @@ -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 Function(Map)? validator}); + +final launchEvents = [ + ( + event: 'widget_preview.started', + validator: (Map 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 runWidgetPreviewMachineMode({ + required List expectedEvents, + bool useWebServer = false, + }) async { + expect(expectedEvents, isNotEmpty); + process = await processManager.start([ + flutterBin, + 'widget-preview', + 'start', + '--machine', + '--${WidgetPreviewStartCommand.kHeadless}', + if (useWebServer) '--${WidgetPreviewStartCommand.kWebServer}', + ], workingDirectory: tempDir.path); + + final completer = Completer(); + 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 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); + }); + }); +}