Codegen an entrypoint for flutter web applications (#33956)
This commit is contained in:
parent
4e5cf5efb0
commit
c91b657116
@ -6,8 +6,6 @@ import 'package:flutter/widgets.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
runApp(Center(
|
runApp(Center(
|
||||||
// Can remove when https://github.com/dart-lang/sdk/issues/35801 is fixed.
|
child: const Text('Hello, World', textDirection: TextDirection.ltr),
|
||||||
// ignore: prefer_const_constructors
|
|
||||||
child: Text('Hello, World', textDirection: TextDirection.ltr),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
@ -1,16 +0,0 @@
|
|||||||
// Copyright 2019 The Chromium Authors. All rights reserved.
|
|
||||||
// Use of this source code is governed by a BSD-style license that can be
|
|
||||||
// found in the LICENSE file.
|
|
||||||
|
|
||||||
// Thanks for checking out Flutter!
|
|
||||||
// Like what you see? Tweet us @FlutterDev
|
|
||||||
|
|
||||||
import 'dart:ui' as ui;
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'gallery/app.dart';
|
|
||||||
|
|
||||||
Future<void> main() async {
|
|
||||||
await ui.webOnlyInitializePlatform(); // ignore: undefined_function
|
|
||||||
runApp(const GalleryApp());
|
|
||||||
}
|
|
@ -3,13 +3,19 @@
|
|||||||
// found in the LICENSE file.
|
// found in the LICENSE file.
|
||||||
|
|
||||||
// ignore_for_file: implementation_imports
|
// ignore_for_file: implementation_imports
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io' as io; // ignore: dart_io_import
|
||||||
|
|
||||||
import 'package:build/build.dart';
|
import 'package:build/build.dart';
|
||||||
import 'package:build_config/build_config.dart';
|
import 'package:build_config/build_config.dart';
|
||||||
import 'package:build_modules/build_modules.dart';
|
import 'package:build_modules/build_modules.dart';
|
||||||
import 'package:build_modules/builders.dart';
|
import 'package:build_modules/builders.dart';
|
||||||
import 'package:build_modules/src/module_builder.dart';
|
import 'package:build_modules/src/module_builder.dart';
|
||||||
import 'package:build_modules/src/platform.dart';
|
import 'package:build_modules/src/platform.dart';
|
||||||
|
import 'package:build_modules/src/workers.dart';
|
||||||
import 'package:build_runner_core/build_runner_core.dart' as core;
|
import 'package:build_runner_core/build_runner_core.dart' as core;
|
||||||
|
import 'package:build_runner_core/src/asset_graph/graph.dart';
|
||||||
|
import 'package:build_runner_core/src/asset_graph/node.dart';
|
||||||
import 'package:build_runner_core/src/generate/build_impl.dart';
|
import 'package:build_runner_core/src/generate/build_impl.dart';
|
||||||
import 'package:build_runner_core/src/generate/options.dart';
|
import 'package:build_runner_core/src/generate/options.dart';
|
||||||
import 'package:build_test/builder.dart';
|
import 'package:build_test/builder.dart';
|
||||||
@ -17,9 +23,11 @@ import 'package:build_test/src/debug_test_builder.dart';
|
|||||||
import 'package:build_web_compilers/build_web_compilers.dart';
|
import 'package:build_web_compilers/build_web_compilers.dart';
|
||||||
import 'package:build_web_compilers/builders.dart';
|
import 'package:build_web_compilers/builders.dart';
|
||||||
import 'package:build_web_compilers/src/dev_compiler_bootstrap.dart';
|
import 'package:build_web_compilers/src/dev_compiler_bootstrap.dart';
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:meta/meta.dart';
|
import 'package:meta/meta.dart';
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:scratch_space/scratch_space.dart';
|
||||||
import 'package:test_core/backend.dart';
|
import 'package:test_core/backend.dart';
|
||||||
import 'package:watcher/watcher.dart';
|
import 'package:watcher/watcher.dart';
|
||||||
|
|
||||||
@ -28,6 +36,7 @@ import '../base/file_system.dart';
|
|||||||
import '../base/logger.dart';
|
import '../base/logger.dart';
|
||||||
import '../base/platform.dart';
|
import '../base/platform.dart';
|
||||||
import '../compile.dart';
|
import '../compile.dart';
|
||||||
|
import '../convert.dart';
|
||||||
import '../dart/package_map.dart';
|
import '../dart/package_map.dart';
|
||||||
import '../globals.dart';
|
import '../globals.dart';
|
||||||
import '../web/compile.dart';
|
import '../web/compile.dart';
|
||||||
@ -83,6 +92,22 @@ final List<core.BuilderApplication> builders = <core.BuilderApplication>[
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
core.apply(
|
||||||
|
'flutter_tools|shell',
|
||||||
|
<BuilderFactory>[
|
||||||
|
(BuilderOptions options) => FlutterWebShellBuilder(
|
||||||
|
options.config['targets'] ?? <String>['lib/main.dart']
|
||||||
|
),
|
||||||
|
],
|
||||||
|
core.toRoot(),
|
||||||
|
hideOutput: true,
|
||||||
|
defaultGenerateFor: const InputSet(
|
||||||
|
include: <String>[
|
||||||
|
'lib/**',
|
||||||
|
'web/**',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
core.apply(
|
core.apply(
|
||||||
'flutter_tools|module_library',
|
'flutter_tools|module_library',
|
||||||
<Builder Function(BuilderOptions)>[moduleLibraryBuilder],
|
<Builder Function(BuilderOptions)>[moduleLibraryBuilder],
|
||||||
@ -127,7 +152,9 @@ final List<core.BuilderApplication> builders = <core.BuilderApplication>[
|
|||||||
'flutter_tools|entrypoint',
|
'flutter_tools|entrypoint',
|
||||||
<BuilderFactory>[
|
<BuilderFactory>[
|
||||||
(BuilderOptions options) => FlutterWebEntrypointBuilder(
|
(BuilderOptions options) => FlutterWebEntrypointBuilder(
|
||||||
options.config['targets'] ?? <String>['lib/main.dart']),
|
options.config['targets'] ?? <String>['lib/main.dart'],
|
||||||
|
options.config['release'],
|
||||||
|
),
|
||||||
],
|
],
|
||||||
core.toRoot(),
|
core.toRoot(),
|
||||||
hideOutput: true,
|
hideOutput: true,
|
||||||
@ -152,11 +179,15 @@ class BuildRunnerWebCompilationProxy extends WebCompilationProxy {
|
|||||||
PackageUriMapper _packageUriMapper;
|
PackageUriMapper _packageUriMapper;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> initialize({
|
Future<bool> initialize({
|
||||||
@required Directory projectDirectory,
|
@required Directory projectDirectory,
|
||||||
@required List<String> targets,
|
@required List<String> targets,
|
||||||
String testOutputDir,
|
String testOutputDir,
|
||||||
|
bool release = false,
|
||||||
}) async {
|
}) async {
|
||||||
|
// Create the .dart_tool directory if it doesn't exist.
|
||||||
|
projectDirectory.childDirectory('.dart_tool').createSync();
|
||||||
|
|
||||||
// Override the generated output directory so this does not conflict with
|
// Override the generated output directory so this does not conflict with
|
||||||
// other build_runner output.
|
// other build_runner output.
|
||||||
core.overrideGeneratedOutputDirectory('flutter_web');
|
core.overrideGeneratedOutputDirectory('flutter_web');
|
||||||
@ -195,19 +226,36 @@ class BuildRunnerWebCompilationProxy extends WebCompilationProxy {
|
|||||||
};
|
};
|
||||||
final Status status =
|
final Status status =
|
||||||
logger.startProgress('Compiling ${targets.first} for the Web...', timeout: null);
|
logger.startProgress('Compiling ${targets.first} for the Web...', timeout: null);
|
||||||
|
core.BuildResult result;
|
||||||
try {
|
try {
|
||||||
_builder = await BuildImpl.create(
|
result = await _runBuilder(
|
||||||
buildOptions,
|
|
||||||
buildEnvironment,
|
buildEnvironment,
|
||||||
builders,
|
buildOptions,
|
||||||
<String, Map<String, dynamic>>{
|
targets,
|
||||||
'flutter_tools|entrypoint': <String, dynamic>{
|
release,
|
||||||
'targets': targets,
|
buildDirs,
|
||||||
}
|
|
||||||
},
|
|
||||||
isReleaseBuild: false,
|
|
||||||
);
|
);
|
||||||
await _builder.run(const <AssetId, ChangeType>{}, buildDirs: buildDirs);
|
return result.status == core.BuildStatus.success;
|
||||||
|
} on core.BuildConfigChangedException {
|
||||||
|
await _cleanAssets(projectDirectory);
|
||||||
|
result = await _runBuilder(
|
||||||
|
buildEnvironment,
|
||||||
|
buildOptions,
|
||||||
|
targets,
|
||||||
|
release,
|
||||||
|
buildDirs,
|
||||||
|
);
|
||||||
|
return result.status == core.BuildStatus.success;
|
||||||
|
} on core.BuildScriptChangedException {
|
||||||
|
await _cleanAssets(projectDirectory);
|
||||||
|
result = await _runBuilder(
|
||||||
|
buildEnvironment,
|
||||||
|
buildOptions,
|
||||||
|
targets,
|
||||||
|
release,
|
||||||
|
buildDirs,
|
||||||
|
);
|
||||||
|
return result.status == core.BuildStatus.success;
|
||||||
} finally {
|
} finally {
|
||||||
status.stop();
|
status.stop();
|
||||||
}
|
}
|
||||||
@ -219,9 +267,8 @@ class BuildRunnerWebCompilationProxy extends WebCompilationProxy {
|
|||||||
logger.startProgress('Recompiling sources...', timeout: null);
|
logger.startProgress('Recompiling sources...', timeout: null);
|
||||||
final Map<AssetId, ChangeType> updates = <AssetId, ChangeType>{};
|
final Map<AssetId, ChangeType> updates = <AssetId, ChangeType>{};
|
||||||
for (Uri input in inputs) {
|
for (Uri input in inputs) {
|
||||||
updates[AssetId.resolve(
|
final AssetId assetId = AssetId.resolve(_packageUriMapper.map(input.toFilePath()).toString());
|
||||||
_packageUriMapper.map(input.toFilePath()).toString())] =
|
updates[assetId] = ChangeType.MODIFY;
|
||||||
ChangeType.MODIFY;
|
|
||||||
}
|
}
|
||||||
core.BuildResult result;
|
core.BuildResult result;
|
||||||
try {
|
try {
|
||||||
@ -231,13 +278,81 @@ class BuildRunnerWebCompilationProxy extends WebCompilationProxy {
|
|||||||
}
|
}
|
||||||
return result.status == core.BuildStatus.success;
|
return result.status == core.BuildStatus.success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Future<core.BuildResult> _runBuilder(core.BuildEnvironment buildEnvironment, BuildOptions buildOptions, List<String> targets, bool release, Set<core.BuildDirectory> buildDirs) async {
|
||||||
|
_builder = await BuildImpl.create(
|
||||||
|
buildOptions,
|
||||||
|
buildEnvironment,
|
||||||
|
builders,
|
||||||
|
<String, Map<String, dynamic>>{
|
||||||
|
'flutter_tools|entrypoint': <String, dynamic>{
|
||||||
|
'targets': targets,
|
||||||
|
'release': release,
|
||||||
|
},
|
||||||
|
'flutter_tools|shell': <String, dynamic>{
|
||||||
|
'targets': targets,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
isReleaseBuild: false,
|
||||||
|
);
|
||||||
|
return _builder.run(
|
||||||
|
const <AssetId, ChangeType>{},
|
||||||
|
buildDirs: buildDirs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cleanAssets(Directory projectDirectory) async {
|
||||||
|
final File assetGraphFile = fs.file(core.assetGraphPath);
|
||||||
|
AssetGraph assetGraph;
|
||||||
|
try {
|
||||||
|
assetGraph = AssetGraph.deserialize(await assetGraphFile.readAsBytes());
|
||||||
|
} catch (_) {
|
||||||
|
printTrace('Failed to clean up asset graph.');
|
||||||
|
}
|
||||||
|
final core.PackageGraph packageGraph = core.PackageGraph.forThisPackage();
|
||||||
|
await _cleanUpSourceOutputs(assetGraph, packageGraph);
|
||||||
|
final Directory cacheDirectory = fs.directory(fs.path.join(
|
||||||
|
projectDirectory.path,
|
||||||
|
'.dart_tool',
|
||||||
|
'build',
|
||||||
|
'flutter_web',
|
||||||
|
));
|
||||||
|
if (assetGraphFile.existsSync()) {
|
||||||
|
assetGraphFile.deleteSync();
|
||||||
|
}
|
||||||
|
if (cacheDirectory.existsSync()) {
|
||||||
|
cacheDirectory.deleteSync(recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cleanUpSourceOutputs(AssetGraph assetGraph, core.PackageGraph packageGraph) async {
|
||||||
|
final core.FileBasedAssetWriter writer = core.FileBasedAssetWriter(packageGraph);
|
||||||
|
if (assetGraph?.outputs == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (AssetId id in assetGraph.outputs) {
|
||||||
|
if (id.package != packageGraph.root.name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final GeneratedAssetNode node = assetGraph.get(id);
|
||||||
|
if (node.wasOutput) {
|
||||||
|
// Note that this does a file.exists check in the root package and
|
||||||
|
// only tries to delete the file if it exists. This way we only
|
||||||
|
// actually delete to_source outputs, without reading in the build
|
||||||
|
// actions.
|
||||||
|
await writer.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A ddc-only entrypoint builder that respects the Flutter target flag.
|
/// A ddc-only entrypoint builder that respects the Flutter target flag.
|
||||||
class FlutterWebEntrypointBuilder implements Builder {
|
class FlutterWebEntrypointBuilder implements Builder {
|
||||||
const FlutterWebEntrypointBuilder(this.targets);
|
const FlutterWebEntrypointBuilder(this.targets, this.release);
|
||||||
|
|
||||||
final List<String> targets;
|
final List<String> targets;
|
||||||
|
final bool release;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, List<String>> get buildExtensions => const <String, List<String>>{
|
Map<String, List<String>> get buildExtensions => const <String, List<String>>{
|
||||||
@ -254,7 +369,7 @@ class FlutterWebEntrypointBuilder implements Builder {
|
|||||||
Future<void> build(BuildStep buildStep) async {
|
Future<void> build(BuildStep buildStep) async {
|
||||||
bool matches = false;
|
bool matches = false;
|
||||||
for (String target in targets) {
|
for (String target in targets) {
|
||||||
if (buildStep.inputId.path.contains(target)) {
|
if (buildStep.inputId.path.contains(fs.path.setExtension(target, '_web_entrypoint.dart'))) {
|
||||||
matches = true;
|
matches = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -263,10 +378,15 @@ class FlutterWebEntrypointBuilder implements Builder {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log.info('building for target ${buildStep.inputId.path}');
|
log.info('building for target ${buildStep.inputId.path}');
|
||||||
await bootstrapDdc(buildStep, platform: flutterWebPlatform);
|
if (release) {
|
||||||
|
await bootstrapDart2Js(buildStep);
|
||||||
|
} else {
|
||||||
|
await bootstrapDdc(buildStep, platform: flutterWebPlatform);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bootstraps the test entrypoint.
|
||||||
class FlutterWebTestBootstrapBuilder implements Builder {
|
class FlutterWebTestBootstrapBuilder implements Builder {
|
||||||
const FlutterWebTestBootstrapBuilder();
|
const FlutterWebTestBootstrapBuilder();
|
||||||
|
|
||||||
@ -372,3 +492,117 @@ void setStackTraceMapper(StackTraceMapper mapper) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A shell builder which generates the web specific entrypoint.
|
||||||
|
class FlutterWebShellBuilder implements Builder {
|
||||||
|
const FlutterWebShellBuilder(this.targets);
|
||||||
|
|
||||||
|
final List<String> targets;
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<void> build(BuildStep buildStep) async {
|
||||||
|
bool matches = false;
|
||||||
|
for (String target in targets) {
|
||||||
|
if (buildStep.inputId.path.contains(target)) {
|
||||||
|
matches = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!matches) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final AssetId outputId = buildStep.inputId.changeExtension('_web_entrypoint.dart');
|
||||||
|
await buildStep.writeAsString(outputId, '''
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
import "${path.url.basename(buildStep.inputId.path)}" as entrypoint;
|
||||||
|
|
||||||
|
Future<void> main() async {
|
||||||
|
await ui.webOnlyInitializePlatform();
|
||||||
|
entrypoint.main();
|
||||||
|
}
|
||||||
|
|
||||||
|
''');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, List<String>> get buildExtensions => const <String, List<String>>{
|
||||||
|
'.dart': <String>['_web_entrypoint.dart'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> bootstrapDart2Js(BuildStep buildStep) async {
|
||||||
|
final AssetId dartEntrypointId = buildStep.inputId;
|
||||||
|
final AssetId moduleId = dartEntrypointId.changeExtension(moduleExtension(flutterWebPlatform));
|
||||||
|
final Module module = Module.fromJson(json.decode(await buildStep.readAsString(moduleId)));
|
||||||
|
|
||||||
|
final List<Module> allDeps = await module.computeTransitiveDependencies(buildStep, throwIfUnsupported: false)..add(module);
|
||||||
|
final ScratchSpace scratchSpace = await buildStep.fetchResource(scratchSpaceResource);
|
||||||
|
final Iterable<AssetId> allSrcs = allDeps.expand((Module module) => module.sources);
|
||||||
|
await scratchSpace.ensureAssets(allSrcs, buildStep);
|
||||||
|
|
||||||
|
final String packageFile = await _createPackageFile(allSrcs, buildStep, scratchSpace);
|
||||||
|
final String dartPath = dartEntrypointId.path.startsWith('lib/')
|
||||||
|
? 'package:${dartEntrypointId.package}/'
|
||||||
|
'${dartEntrypointId.path.substring('lib/'.length)}'
|
||||||
|
: dartEntrypointId.path;
|
||||||
|
final String jsOutputPath =
|
||||||
|
'${fs.path.withoutExtension(dartPath.replaceFirst('package:', 'packages/'))}'
|
||||||
|
'$jsEntrypointExtension';
|
||||||
|
final String flutterWebSdkPath = artifacts.getArtifactPath(Artifact.flutterWebSdk);
|
||||||
|
final String librariesPath = fs.path.join(flutterWebSdkPath, 'libraries.json');
|
||||||
|
final List<String> args = <String>[
|
||||||
|
'--libraries-spec="$librariesPath"',
|
||||||
|
'-m',
|
||||||
|
'-o4',
|
||||||
|
'-o',
|
||||||
|
'$jsOutputPath',
|
||||||
|
'--packages="$packageFile"',
|
||||||
|
dartPath,
|
||||||
|
];
|
||||||
|
final Dart2JsBatchWorkerPool dart2js = await buildStep.fetchResource(dart2JsWorkerResource);
|
||||||
|
final Dart2JsResult result = await dart2js.compile(args);
|
||||||
|
final AssetId jsOutputId = dartEntrypointId.changeExtension(jsEntrypointExtension);
|
||||||
|
final io.File jsOutputFile = scratchSpace.fileFor(jsOutputId);
|
||||||
|
if (result.succeeded && jsOutputFile.existsSync()) {
|
||||||
|
log.info(result.output);
|
||||||
|
// Explicitly write out the original js file and sourcemap.
|
||||||
|
await scratchSpace.copyOutput(jsOutputId, buildStep);
|
||||||
|
final AssetId jsSourceMapId =
|
||||||
|
dartEntrypointId.changeExtension(jsEntrypointSourceMapExtension);
|
||||||
|
await _copyIfExists(jsSourceMapId, scratchSpace, buildStep);
|
||||||
|
} else {
|
||||||
|
log.severe(result.output);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _copyIfExists(
|
||||||
|
AssetId id, ScratchSpace scratchSpace, AssetWriter writer) async {
|
||||||
|
final io.File file = scratchSpace.fileFor(id);
|
||||||
|
if (file.existsSync()) {
|
||||||
|
await scratchSpace.copyOutput(id, writer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a `.packages` file unique to this entrypoint at the root of the
|
||||||
|
/// scratch space and returns it's filename.
|
||||||
|
///
|
||||||
|
/// Since mulitple invocations of Dart2Js will share a scratch space and we only
|
||||||
|
/// know the set of packages involved the current entrypoint we can't construct
|
||||||
|
/// a `.packages` file that will work for all invocations of Dart2Js so a unique
|
||||||
|
/// file is created for every entrypoint that is run.
|
||||||
|
///
|
||||||
|
/// The filename is based off the MD5 hash of the asset path so that files are
|
||||||
|
/// unique regarless of situations like `web/foo/bar.dart` vs
|
||||||
|
/// `web/foo-bar.dart`.
|
||||||
|
Future<String> _createPackageFile(Iterable<AssetId> inputSources, BuildStep buildStep, ScratchSpace scratchSpace) async {
|
||||||
|
final Uri inputUri = buildStep.inputId.uri;
|
||||||
|
final String packageFileName =
|
||||||
|
'.package-${md5.convert(inputUri.toString().codeUnits)}';
|
||||||
|
final io.File packagesFile =
|
||||||
|
scratchSpace.fileFor(AssetId(buildStep.inputId.package, packageFileName));
|
||||||
|
final Set<String> packageNames = inputSources.map((AssetId s) => s.package).toSet();
|
||||||
|
final String packagesFileContent =
|
||||||
|
packageNames.map((String name) => '$name:packages/$name/').join('\n');
|
||||||
|
await packagesFile
|
||||||
|
.writeAsString('# Generated for $inputUri\n$packagesFileContent');
|
||||||
|
return packageFileName;
|
||||||
|
}
|
||||||
|
@ -4,10 +4,8 @@
|
|||||||
|
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import '../base/common.dart';
|
|
||||||
import '../base/logger.dart';
|
|
||||||
import '../build_info.dart';
|
import '../build_info.dart';
|
||||||
import '../globals.dart';
|
import '../project.dart';
|
||||||
import '../runner/flutter_command.dart'
|
import '../runner/flutter_command.dart'
|
||||||
show DevelopmentArtifact, FlutterCommandResult;
|
show DevelopmentArtifact, FlutterCommandResult;
|
||||||
import '../web/compile.dart';
|
import '../web/compile.dart';
|
||||||
@ -41,34 +39,10 @@ class BuildWebCommand extends BuildSubCommand {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<FlutterCommandResult> runCommand() async {
|
Future<FlutterCommandResult> runCommand() async {
|
||||||
|
final FlutterProject flutterProject = FlutterProject.current();
|
||||||
final String target = argResults['target'];
|
final String target = argResults['target'];
|
||||||
final Status status = logger
|
|
||||||
.startProgress('Compiling $target for the Web...', timeout: null);
|
|
||||||
final BuildInfo buildInfo = getBuildInfo();
|
final BuildInfo buildInfo = getBuildInfo();
|
||||||
int result;
|
await buildWeb(flutterProject, target, buildInfo);
|
||||||
switch (buildInfo.mode) {
|
|
||||||
case BuildMode.release:
|
|
||||||
result = await webCompiler.compileDart2js(target: target);
|
|
||||||
break;
|
|
||||||
case BuildMode.profile:
|
|
||||||
result = await webCompiler.compileDart2js(target: target, minify: false);
|
|
||||||
break;
|
|
||||||
case BuildMode.debug:
|
|
||||||
throwToolExit(
|
|
||||||
'Debug mode is not supported as a build target. Instead use '
|
|
||||||
'"flutter run -d web".');
|
|
||||||
break;
|
|
||||||
case BuildMode.dynamicProfile:
|
|
||||||
case BuildMode.dynamicRelease:
|
|
||||||
throwToolExit(
|
|
||||||
'Build mode ${buildInfo.mode} is not supported with JavaScript '
|
|
||||||
'compilation');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
status.stop();
|
|
||||||
if (result == 1) {
|
|
||||||
throwToolExit('Failed to compile $target to JavaScript.');
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -44,7 +44,6 @@ import 'run_hot.dart';
|
|||||||
import 'usage.dart';
|
import 'usage.dart';
|
||||||
import 'version.dart';
|
import 'version.dart';
|
||||||
import 'web/chrome.dart';
|
import 'web/chrome.dart';
|
||||||
import 'web/compile.dart';
|
|
||||||
import 'web/workflow.dart';
|
import 'web/workflow.dart';
|
||||||
import 'windows/visual_studio.dart';
|
import 'windows/visual_studio.dart';
|
||||||
import 'windows/visual_studio_validator.dart';
|
import 'windows/visual_studio_validator.dart';
|
||||||
@ -104,7 +103,6 @@ Future<T> runInContext<T>(
|
|||||||
UserMessages: () => UserMessages(),
|
UserMessages: () => UserMessages(),
|
||||||
VisualStudio: () => VisualStudio(),
|
VisualStudio: () => VisualStudio(),
|
||||||
VisualStudioValidator: () => const VisualStudioValidator(),
|
VisualStudioValidator: () => const VisualStudioValidator(),
|
||||||
WebCompiler: () => const WebCompiler(),
|
|
||||||
WebWorkflow: () => const WebWorkflow(),
|
WebWorkflow: () => const WebWorkflow(),
|
||||||
WindowsWorkflow: () => const WindowsWorkflow(),
|
WindowsWorkflow: () => const WindowsWorkflow(),
|
||||||
Xcode: () => Xcode(),
|
Xcode: () => Xcode(),
|
||||||
|
@ -583,6 +583,9 @@ class WebProject {
|
|||||||
return parent.directory.childDirectory('web').existsSync();
|
return parent.directory.childDirectory('web').existsSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The html file used to host the flutter web application.
|
||||||
|
File get indexFile => parent.directory.childDirectory('web').childFile('index.html');
|
||||||
|
|
||||||
Future<void> ensureReadyForPlatformSpecificTooling() async {
|
Future<void> ensureReadyForPlatformSpecificTooling() async {
|
||||||
/// Generate index.html in build/web. Eventually we could support
|
/// Generate index.html in build/web. Eventually we could support
|
||||||
/// a custom html under the web sub directory.
|
/// a custom html under the web sub directory.
|
||||||
|
@ -66,6 +66,7 @@ class WebAssetServer {
|
|||||||
|
|
||||||
/// An HTTP server which provides JavaScript and web assets to the browser.
|
/// An HTTP server which provides JavaScript and web assets to the browser.
|
||||||
Future<void> _onRequest(HttpRequest request) async {
|
Future<void> _onRequest(HttpRequest request) async {
|
||||||
|
final String targetName = '${fs.path.basenameWithoutExtension(target)}_web_entrypoint';
|
||||||
if (request.method != 'GET') {
|
if (request.method != 'GET') {
|
||||||
request.response.statusCode = HttpStatus.forbidden;
|
request.response.statusCode = HttpStatus.forbidden;
|
||||||
await request.response.close();
|
await request.response.close();
|
||||||
@ -103,17 +104,17 @@ class WebAssetServer {
|
|||||||
'flutter_web',
|
'flutter_web',
|
||||||
flutterProject.manifest.appName,
|
flutterProject.manifest.appName,
|
||||||
'lib',
|
'lib',
|
||||||
'${fs.path.basename(target)}.js',
|
'$targetName.dart.js',
|
||||||
));
|
));
|
||||||
await _completeRequest(request, file, 'text/javascript');
|
await _completeRequest(request, file, 'text/javascript');
|
||||||
} else if (uri.path.endsWith('${fs.path.basename(target)}.bootstrap.js')) {
|
} else if (uri.path.endsWith('$targetName.dart.bootstrap.js')) {
|
||||||
final File file = fs.file(fs.path.join(
|
final File file = fs.file(fs.path.join(
|
||||||
flutterProject.dartTool.path,
|
flutterProject.dartTool.path,
|
||||||
'build',
|
'build',
|
||||||
'flutter_web',
|
'flutter_web',
|
||||||
flutterProject.manifest.appName,
|
flutterProject.manifest.appName,
|
||||||
'lib',
|
'lib',
|
||||||
'${fs.path.basename(target)}.bootstrap.js',
|
'$targetName.dart.bootstrap.js',
|
||||||
));
|
));
|
||||||
await _completeRequest(request, file, 'text/javascript');
|
await _completeRequest(request, file, 'text/javascript');
|
||||||
} else if (uri.path.contains('dart_sdk')) {
|
} else if (uri.path.contains('dart_sdk')) {
|
||||||
|
@ -4,80 +4,56 @@
|
|||||||
|
|
||||||
import 'package:meta/meta.dart';
|
import 'package:meta/meta.dart';
|
||||||
|
|
||||||
import '../artifacts.dart';
|
import '../asset.dart';
|
||||||
import '../base/common.dart';
|
import '../base/common.dart';
|
||||||
import '../base/context.dart';
|
import '../base/context.dart';
|
||||||
import '../base/file_system.dart';
|
import '../base/file_system.dart';
|
||||||
import '../base/io.dart';
|
import '../base/logger.dart';
|
||||||
import '../base/process_manager.dart';
|
|
||||||
import '../build_info.dart';
|
import '../build_info.dart';
|
||||||
import '../convert.dart';
|
import '../bundle.dart';
|
||||||
import '../globals.dart';
|
import '../globals.dart';
|
||||||
|
import '../project.dart';
|
||||||
/// The [WebCompiler] instance.
|
|
||||||
WebCompiler get webCompiler => context.get<WebCompiler>();
|
|
||||||
|
|
||||||
/// The [WebCompilationProxy] instance.
|
/// The [WebCompilationProxy] instance.
|
||||||
WebCompilationProxy get webCompilationProxy =>
|
WebCompilationProxy get webCompilationProxy => context.get<WebCompilationProxy>();
|
||||||
context.get<WebCompilationProxy>();
|
|
||||||
|
|
||||||
/// A wrapper around dart tools for web compilation.
|
Future<void> buildWeb(FlutterProject flutterProject, String target, BuildInfo buildInfo) async {
|
||||||
class WebCompiler {
|
final Status status = logger.startProgress('Compiling $target for the Web...', timeout: null);
|
||||||
const WebCompiler();
|
final Directory outputDir = fs.directory(getWebBuildDirectory())
|
||||||
|
..createSync(recursive: true);
|
||||||
|
bool result;
|
||||||
|
try {
|
||||||
|
result = await webCompilationProxy.initialize(
|
||||||
|
projectDirectory: FlutterProject.current().directory,
|
||||||
|
targets: <String>[target],
|
||||||
|
release: buildInfo.isRelease,
|
||||||
|
);
|
||||||
|
if (result) {
|
||||||
|
// Places assets adjacent to the web stuff.
|
||||||
|
final AssetBundle assetBundle = AssetBundleFactory.instance.createBundle();
|
||||||
|
await assetBundle.build();
|
||||||
|
await writeBundle(fs.directory(fs.path.join(outputDir.path, 'assets')), assetBundle.entries);
|
||||||
|
|
||||||
/// Compile `target` using dart2js.
|
// Copy results to output directory.
|
||||||
///
|
final String outputPath = fs.path.join(
|
||||||
/// `minify` controls whether minifaction of the source is enabled. Defaults to `true`.
|
flutterProject.dartTool.path,
|
||||||
/// `enabledAssertions` controls whether assertions are enabled. Defaults to `false`.
|
'build',
|
||||||
Future<int> compileDart2js({
|
'flutter_web',
|
||||||
@required String target,
|
flutterProject.manifest.appName,
|
||||||
bool minify = true,
|
'${fs.path.withoutExtension(target)}_web_entrypoint.dart.js'
|
||||||
bool enabledAssertions = false,
|
);
|
||||||
}) async {
|
fs.file(outputPath).copySync(fs.path.join(outputDir.path, 'main.dart.js'));
|
||||||
final String engineDartPath =
|
fs.file('$outputPath.map').copySync(fs.path.join(outputDir.path, 'main.dart.js.map'));
|
||||||
artifacts.getArtifactPath(Artifact.engineDartBinary);
|
flutterProject.web.indexFile.copySync(fs.path.join(outputDir.path, 'index.html'));
|
||||||
final String dart2jsPath =
|
|
||||||
artifacts.getArtifactPath(Artifact.dart2jsSnapshot);
|
|
||||||
final String flutterWebSdkPath =
|
|
||||||
artifacts.getArtifactPath(Artifact.flutterWebSdk);
|
|
||||||
final String librariesPath =
|
|
||||||
fs.path.join(flutterWebSdkPath, 'libraries.json');
|
|
||||||
final Directory outputDir = fs.directory(getWebBuildDirectory());
|
|
||||||
if (!outputDir.existsSync()) {
|
|
||||||
outputDir.createSync(recursive: true);
|
|
||||||
}
|
}
|
||||||
final String outputPath = fs.path.join(outputDir.path, 'main.dart.js');
|
} catch (err) {
|
||||||
if (!processManager.canRun(engineDartPath)) {
|
printError(err.toString());
|
||||||
throwToolExit('Unable to find Dart binary at $engineDartPath');
|
result = false;
|
||||||
}
|
} finally {
|
||||||
|
status.stop();
|
||||||
/// Compile Dart to JavaScript.
|
}
|
||||||
final List<String> command = <String>[
|
if (result == false) {
|
||||||
engineDartPath,
|
throwToolExit('Failed to compile $target for the Web.');
|
||||||
dart2jsPath,
|
|
||||||
target,
|
|
||||||
'-o',
|
|
||||||
'$outputPath',
|
|
||||||
'-O4',
|
|
||||||
'--libraries-spec=$librariesPath',
|
|
||||||
];
|
|
||||||
if (minify) {
|
|
||||||
command.add('-m');
|
|
||||||
}
|
|
||||||
if (enabledAssertions) {
|
|
||||||
command.add('--enable-asserts');
|
|
||||||
}
|
|
||||||
printTrace(command.join(' '));
|
|
||||||
final Process result = await processManager.start(command);
|
|
||||||
result.stdout
|
|
||||||
.transform(utf8.decoder)
|
|
||||||
.transform(const LineSplitter())
|
|
||||||
.listen(printStatus);
|
|
||||||
result.stderr
|
|
||||||
.transform(utf8.decoder)
|
|
||||||
.transform(const LineSplitter())
|
|
||||||
.listen(printError);
|
|
||||||
return result.exitCode;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,12 +63,19 @@ class WebCompiler {
|
|||||||
class WebCompilationProxy {
|
class WebCompilationProxy {
|
||||||
const WebCompilationProxy();
|
const WebCompilationProxy();
|
||||||
|
|
||||||
/// Initialize the web compiler output to `outputDirectory` from a project spawned at
|
/// Initialize the web compiler from the `projectDirectory`.
|
||||||
/// `projectDirectory`.
|
///
|
||||||
Future<void> initialize({
|
/// Returns whether or not the build was successful.
|
||||||
|
///
|
||||||
|
/// `release` controls whether we build the bundle for dartdevc or only
|
||||||
|
/// the entrypoints for dart2js to later take over.
|
||||||
|
///
|
||||||
|
/// `targets` controls the specific compiler targets.
|
||||||
|
Future<bool> initialize({
|
||||||
@required Directory projectDirectory,
|
@required Directory projectDirectory,
|
||||||
@required List<String> targets,
|
@required List<String> targets,
|
||||||
String testOutputDir,
|
String testOutputDir,
|
||||||
|
bool release,
|
||||||
}) async {
|
}) async {
|
||||||
throw UnimplementedError();
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
|
@ -5,15 +5,11 @@
|
|||||||
import 'package:meta/meta.dart';
|
import 'package:meta/meta.dart';
|
||||||
|
|
||||||
import '../application_package.dart';
|
import '../application_package.dart';
|
||||||
import '../asset.dart';
|
|
||||||
import '../base/common.dart';
|
|
||||||
import '../base/file_system.dart';
|
import '../base/file_system.dart';
|
||||||
import '../base/io.dart';
|
import '../base/io.dart';
|
||||||
import '../base/logger.dart';
|
|
||||||
import '../base/platform.dart';
|
import '../base/platform.dart';
|
||||||
import '../base/process_manager.dart';
|
import '../base/process_manager.dart';
|
||||||
import '../build_info.dart';
|
import '../build_info.dart';
|
||||||
import '../bundle.dart';
|
|
||||||
import '../device.dart';
|
import '../device.dart';
|
||||||
import '../globals.dart';
|
import '../globals.dart';
|
||||||
import '../project.dart';
|
import '../project.dart';
|
||||||
@ -22,15 +18,15 @@ import '../web/workflow.dart';
|
|||||||
import 'chrome.dart';
|
import 'chrome.dart';
|
||||||
|
|
||||||
class WebApplicationPackage extends ApplicationPackage {
|
class WebApplicationPackage extends ApplicationPackage {
|
||||||
WebApplicationPackage(this._flutterProject) : super(id: _flutterProject.manifest.appName);
|
WebApplicationPackage(this.flutterProject) : super(id: flutterProject.manifest.appName);
|
||||||
|
|
||||||
final FlutterProject _flutterProject;
|
final FlutterProject flutterProject;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get name => _flutterProject.manifest.appName;
|
String get name => flutterProject.manifest.appName;
|
||||||
|
|
||||||
/// The location of the web source assets.
|
/// The location of the web source assets.
|
||||||
Directory get webSourcePath => _flutterProject.directory.childDirectory('web');
|
Directory get webSourcePath => flutterProject.directory.childDirectory('web');
|
||||||
}
|
}
|
||||||
|
|
||||||
class WebDevice extends Device {
|
class WebDevice extends Device {
|
||||||
@ -121,20 +117,11 @@ class WebDevice extends Device {
|
|||||||
bool usesTerminalUi = true,
|
bool usesTerminalUi = true,
|
||||||
bool ipv6 = false,
|
bool ipv6 = false,
|
||||||
}) async {
|
}) async {
|
||||||
final Status status = logger.startProgress('Compiling ${package.name} to JavaScript...', timeout: null);
|
await buildWeb(
|
||||||
final int result = await webCompiler.compileDart2js(target: mainPath, minify: false, enabledAssertions: true);
|
package.flutterProject,
|
||||||
status.stop();
|
fs.path.relative(mainPath, from: package.flutterProject.directory.path),
|
||||||
if (result != 0) {
|
debuggingOptions.buildInfo,
|
||||||
printError('Failed to compile ${package.name} to JavaScript');
|
);
|
||||||
return LaunchResult.failed();
|
|
||||||
}
|
|
||||||
final AssetBundle assetBundle = AssetBundleFactory.instance.createBundle();
|
|
||||||
final int build = await assetBundle.build();
|
|
||||||
if (build != 0) {
|
|
||||||
throwToolExit('Error: Failed to build asset bundle');
|
|
||||||
}
|
|
||||||
await writeBundle(fs.directory(getAssetBuildDirectory()), assetBundle.entries);
|
|
||||||
|
|
||||||
_package = package;
|
_package = package;
|
||||||
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||||
_server.listen(_basicAssetServer);
|
_server.listen(_basicAssetServer);
|
||||||
|
@ -1,50 +0,0 @@
|
|||||||
// Copyright 2019 The Chromium 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 'package:flutter_tools/src/artifacts.dart';
|
|
||||||
import 'package:flutter_tools/src/globals.dart';
|
|
||||||
import 'package:flutter_tools/src/web/compile.dart';
|
|
||||||
import 'package:mockito/mockito.dart';
|
|
||||||
import 'package:process/process.dart';
|
|
||||||
|
|
||||||
import '../src/common.dart';
|
|
||||||
import '../src/mocks.dart';
|
|
||||||
import '../src/testbed.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group(WebCompiler, () {
|
|
||||||
MockProcessManager mockProcessManager;
|
|
||||||
Testbed testBed;
|
|
||||||
|
|
||||||
setUp(() {
|
|
||||||
mockProcessManager = MockProcessManager();
|
|
||||||
testBed = Testbed(setup: () async {
|
|
||||||
final String engineDartPath = artifacts.getArtifactPath(Artifact.engineDartBinary);
|
|
||||||
when(mockProcessManager.start(any)).thenAnswer((Invocation invocation) async => FakeProcess());
|
|
||||||
when(mockProcessManager.canRun(engineDartPath)).thenReturn(true);
|
|
||||||
|
|
||||||
}, overrides: <Type, Generator>{
|
|
||||||
ProcessManager: () => mockProcessManager,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('invokes dart2js with correct arguments', () => testBed.run(() async {
|
|
||||||
await webCompiler.compileDart2js(target: 'lib/main.dart');
|
|
||||||
|
|
||||||
verify(mockProcessManager.start(<String>[
|
|
||||||
'bin/cache/dart-sdk/bin/dart',
|
|
||||||
'bin/cache/dart-sdk/bin/snapshots/dart2js.dart.snapshot',
|
|
||||||
'lib/main.dart',
|
|
||||||
'-o',
|
|
||||||
'build/web/main.dart.js',
|
|
||||||
'-O4',
|
|
||||||
'--libraries-spec=bin/cache/flutter_web_sdk/libraries.json',
|
|
||||||
'-m',
|
|
||||||
])).called(1);
|
|
||||||
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
class MockProcessManager extends Mock implements ProcessManager {}
|
|
@ -2,12 +2,9 @@
|
|||||||
// Use of this source code is governed by a BSD-style license that can be
|
// Use of this source code is governed by a BSD-style license that can be
|
||||||
// found in the LICENSE file.
|
// found in the LICENSE file.
|
||||||
|
|
||||||
import 'package:flutter_tools/src/base/file_system.dart';
|
|
||||||
import 'package:flutter_tools/src/base/io.dart';
|
import 'package:flutter_tools/src/base/io.dart';
|
||||||
import 'package:flutter_tools/src/base/platform.dart';
|
import 'package:flutter_tools/src/base/platform.dart';
|
||||||
import 'package:flutter_tools/src/project.dart';
|
|
||||||
import 'package:flutter_tools/src/web/chrome.dart';
|
import 'package:flutter_tools/src/web/chrome.dart';
|
||||||
import 'package:flutter_tools/src/web/compile.dart';
|
|
||||||
import 'package:flutter_tools/src/web/web_device.dart';
|
import 'package:flutter_tools/src/web/web_device.dart';
|
||||||
import 'package:mockito/mockito.dart';
|
import 'package:mockito/mockito.dart';
|
||||||
import 'package:process/process.dart';
|
import 'package:process/process.dart';
|
||||||
@ -17,37 +14,18 @@ import '../src/context.dart';
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group(WebDevice, () {
|
group(WebDevice, () {
|
||||||
MockWebCompiler mockWebCompiler;
|
|
||||||
MockChromeLauncher mockChromeLauncher;
|
MockChromeLauncher mockChromeLauncher;
|
||||||
MockPlatform mockPlatform;
|
MockPlatform mockPlatform;
|
||||||
FlutterProject flutterProject;
|
|
||||||
MockProcessManager mockProcessManager;
|
MockProcessManager mockProcessManager;
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
mockProcessManager = MockProcessManager();
|
mockProcessManager = MockProcessManager();
|
||||||
mockChromeLauncher = MockChromeLauncher();
|
mockChromeLauncher = MockChromeLauncher();
|
||||||
mockPlatform = MockPlatform();
|
mockPlatform = MockPlatform();
|
||||||
mockWebCompiler = MockWebCompiler();
|
|
||||||
flutterProject = FlutterProject.fromPath(fs.path.join(getFlutterRoot(), 'dev', 'integration_tests', 'web'));
|
|
||||||
when(mockWebCompiler.compileDart2js(
|
|
||||||
target: anyNamed('target'),
|
|
||||||
minify: anyNamed('minify'),
|
|
||||||
enabledAssertions: anyNamed('enabledAssertions'),
|
|
||||||
)).thenAnswer((Invocation invocation) async => 0);
|
|
||||||
when(mockChromeLauncher.launch(any)).thenAnswer((Invocation invocation) async {
|
when(mockChromeLauncher.launch(any)).thenAnswer((Invocation invocation) async {
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
testUsingContext('can build and connect to chrome', () async {
|
|
||||||
final WebDevice device = WebDevice();
|
|
||||||
await device.startApp(WebApplicationPackage(flutterProject));
|
|
||||||
}, overrides: <Type, Generator>{
|
|
||||||
ChromeLauncher: () => mockChromeLauncher,
|
|
||||||
WebCompiler: () => mockWebCompiler,
|
|
||||||
Platform: () => mockPlatform,
|
|
||||||
});
|
|
||||||
|
|
||||||
testUsingContext('Invokes version command on non-Windows platforms', () async{
|
testUsingContext('Invokes version command on non-Windows platforms', () async{
|
||||||
when(mockPlatform.isWindows).thenReturn(false);
|
when(mockPlatform.isWindows).thenReturn(false);
|
||||||
when(mockPlatform.environment).thenReturn(<String, String>{
|
when(mockPlatform.environment).thenReturn(<String, String>{
|
||||||
@ -86,7 +64,6 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class MockChromeLauncher extends Mock implements ChromeLauncher {}
|
class MockChromeLauncher extends Mock implements ChromeLauncher {}
|
||||||
class MockWebCompiler extends Mock implements WebCompiler {}
|
|
||||||
class MockPlatform extends Mock implements Platform {}
|
class MockPlatform extends Mock implements Platform {}
|
||||||
class MockProcessManager extends Mock implements ProcessManager {}
|
class MockProcessManager extends Mock implements ProcessManager {}
|
||||||
class MockProcessResult extends Mock implements ProcessResult {
|
class MockProcessResult extends Mock implements ProcessResult {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user