blob: c5bd533f12712d80dfda341ac4de80607a275322 [file] [log] [blame]
// 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 'package:file/memory.dart';
import 'package:file_testing/file_testing.dart';
import 'package:flutter_tools/src/artifacts.dart';
import 'package:flutter_tools/src/base/file_system.dart';
import 'package:flutter_tools/src/base/logger.dart';
import 'package:flutter_tools/src/base/platform.dart';
import 'package:flutter_tools/src/base/utils.dart';
import 'package:flutter_tools/src/build_system/build_system.dart';
import 'package:flutter_tools/src/build_system/exceptions.dart';
import 'package:flutter_tools/src/convert.dart';
import '../../src/common.dart';
import '../../src/fake_process_manager.dart';
void main() {
late FileSystem fileSystem;
late Environment environment;
late Target fooTarget;
late Target barTarget;
late Target fizzTarget;
late Target sharedTarget;
late int fooInvocations;
late int barInvocations;
late int shared;
setUp(() {
fileSystem = MemoryFileSystem.test();
fooInvocations = 0;
barInvocations = 0;
shared = 0;
/// Create various test targets.
fooTarget = TestTarget((Environment environment) async {
environment
.buildDir
.childFile('out')
..createSync(recursive: true)
..writeAsStringSync('hey');
fooInvocations++;
})
..name = 'foo'
..inputs = const <Source>[
Source.pattern('{PROJECT_DIR}/foo.dart'),
]
..outputs = const <Source>[
Source.pattern('{BUILD_DIR}/out'),
]
..dependencies = <Target>[];
barTarget = TestTarget((Environment environment) async {
environment.buildDir
.childFile('bar')
..createSync(recursive: true)
..writeAsStringSync('there');
barInvocations++;
})
..name = 'bar'
..inputs = const <Source>[
Source.pattern('{BUILD_DIR}/out'),
]
..outputs = const <Source>[
Source.pattern('{BUILD_DIR}/bar'),
]
..dependencies = <Target>[];
fizzTarget = TestTarget((Environment environment) async {
throw Exception('something bad happens');
})
..name = 'fizz'
..inputs = const <Source>[
Source.pattern('{BUILD_DIR}/out'),
]
..outputs = const <Source>[
Source.pattern('{BUILD_DIR}/fizz'),
]
..dependencies = <Target>[fooTarget];
sharedTarget = TestTarget((Environment environment) async {
shared += 1;
})
..name = 'shared'
..inputs = const <Source>[
Source.pattern('{PROJECT_DIR}/foo.dart'),
];
final Artifacts artifacts = Artifacts.test();
environment = Environment.test(
fileSystem.currentDirectory,
artifacts: artifacts,
processManager: FakeProcessManager.any(),
fileSystem: fileSystem,
logger: BufferLogger.test(),
);
fileSystem.file('foo.dart')
..createSync(recursive: true)
..writeAsStringSync('');
fileSystem.file('pubspec.yaml').createSync();
});
testWithoutContext('Does not throw exception if asked to build with missing inputs', () async {
final BuildSystem buildSystem = setUpBuildSystem(fileSystem);
// Delete required input file.
fileSystem.file('foo.dart').deleteSync();
final BuildResult buildResult = await buildSystem.build(fooTarget, environment);
expect(buildResult.hasException, false);
});
testWithoutContext('Does not throw exception if it does not produce a specified output', () async {
final BuildSystem buildSystem = setUpBuildSystem(fileSystem);
// This target is document as producing foo.dart but does not actually
// output this value.
final Target badTarget = TestTarget((Environment environment) async {})
..inputs = const <Source>[
Source.pattern('{PROJECT_DIR}/foo.dart'),
]
..outputs = const <Source>[
Source.pattern('{BUILD_DIR}/out'),
];
final BuildResult result = await buildSystem.build(badTarget, environment);
expect(result.hasException, false);
});
testWithoutContext('Saves a stamp file with inputs and outputs', () async {
final BuildSystem buildSystem = setUpBuildSystem(fileSystem);
await buildSystem.build(fooTarget, environment);
final File stampFile = fileSystem.file(
'${environment.buildDir.path}/foo.stamp');
expect(stampFile, exists);
final Map<String, Object?>? stampContents = castStringKeyedMap(
json.decode(stampFile.readAsStringSync()));
expect(stampContents, containsPair('inputs', <Object>['/foo.dart']));
});
testWithoutContext('Creates a BuildResult with inputs and outputs', () async {
final BuildSystem buildSystem = setUpBuildSystem(fileSystem);
final BuildResult result = await buildSystem.build(fooTarget, environment);
expect(result.inputFiles.single.path, '/foo.dart');
expect(result.outputFiles.single.path, '${environment.buildDir.path}/out');
});
testWithoutContext('Does not re-invoke build if stamp is valid', () async {
final BuildSystem buildSystem = setUpBuildSystem(fileSystem);
await buildSystem.build(fooTarget, environment);
await buildSystem.build(fooTarget, environment);
expect(fooInvocations, 1);
});
testWithoutContext('Re-invoke build if input is modified', () async {
final BuildSystem buildSystem = setUpBuildSystem(fileSystem);
await buildSystem.build(fooTarget, environment);
fileSystem.file('foo.dart').writeAsStringSync('new contents');
await buildSystem.build(fooTarget, environment);
expect(fooInvocations, 2);
});
testWithoutContext('does not re-invoke build if input timestamp changes', () async {
final BuildSystem buildSystem = setUpBuildSystem(fileSystem);
await buildSystem.build(fooTarget, environment);
// The file was previously empty so this does not modify it.
fileSystem.file('foo.dart').writeAsStringSync('');
await buildSystem.build(fooTarget, environment);
expect(fooInvocations, 1);
});
testWithoutContext('does not re-invoke build if output timestamp changes', () async {
final BuildSystem buildSystem = setUpBuildSystem(fileSystem);
await buildSystem.build(fooTarget, environment);
// This is the same content that the output file previously
// contained.
environment.buildDir.childFile('out').writeAsStringSync('hey');
await buildSystem.build(fooTarget, environment);
expect(fooInvocations, 1);
});
testWithoutContext('Re-invoke build if output is modified', () async {
final BuildSystem buildSystem = setUpBuildSystem(fileSystem);
await buildSystem.build(fooTarget, environment);
environment.buildDir.childFile('out').writeAsStringSync('Something different');
await buildSystem.build(fooTarget, environment);
expect(fooInvocations, 2);
});
testWithoutContext('Runs dependencies of targets', () async {
final BuildSystem buildSystem = setUpBuildSystem(fileSystem);
barTarget.dependencies.add(fooTarget);
await buildSystem.build(barTarget, environment);
expect(fileSystem.file('${environment.buildDir.path}/bar'), exists);
expect(fooInvocations, 1);
expect(barInvocations, 1);
});
testWithoutContext('Only invokes shared dependencies once', () async {
final BuildSystem buildSystem = setUpBuildSystem(fileSystem);
fooTarget.dependencies.add(sharedTarget);
barTarget.dependencies.add(sharedTarget);
barTarget.dependencies.add(fooTarget);
await buildSystem.build(barTarget, environment);
expect(shared, 1);
});
testWithoutContext('Automatically cleans old outputs when build graph changes', () async {
final BuildSystem buildSystem = setUpBuildSystem(fileSystem);
final TestTarget testTarget = TestTarget((Environment environment) async {
environment.buildDir.childFile('foo.out').createSync();
})
..inputs = const <Source>[Source.pattern('{PROJECT_DIR}/foo.dart')]
..outputs = const <Source>[Source.pattern('{BUILD_DIR}/foo.out')];
fileSystem.file('foo.dart').createSync();
await buildSystem.build(testTarget, environment);
expect(environment.buildDir.childFile('foo.out'), exists);
final TestTarget testTarget2 = TestTarget((Environment environment) async {
environment.buildDir.childFile('bar.out').createSync();
})
..inputs = const <Source>[Source.pattern('{PROJECT_DIR}/foo.dart')]
..outputs = const <Source>[Source.pattern('{BUILD_DIR}/bar.out')];
await buildSystem.build(testTarget2, environment);
expect(environment.buildDir.childFile('bar.out'), exists);
expect(environment.buildDir.childFile('foo.out'), isNot(exists));
});
testWithoutContext('Does not crash when filesystem and cache are out of sync', () async {
final BuildSystem buildSystem = setUpBuildSystem(fileSystem);
final TestTarget testWithoutContextTarget = TestTarget((Environment environment) async {
environment.buildDir.childFile('foo.out').createSync();
})
..inputs = const <Source>[Source.pattern('{PROJECT_DIR}/foo.dart')]
..outputs = const <Source>[Source.pattern('{BUILD_DIR}/foo.out')];
fileSystem.file('foo.dart').createSync();
await buildSystem.build(testWithoutContextTarget, environment);
expect(environment.buildDir.childFile('foo.out'), exists);
environment.buildDir.childFile('foo.out').deleteSync();
final TestTarget testWithoutContextTarget2 = TestTarget((Environment environment) async {
environment.buildDir.childFile('bar.out').createSync();
})
..inputs = const <Source>[Source.pattern('{PROJECT_DIR}/foo.dart')]
..outputs = const <Source>[Source.pattern('{BUILD_DIR}/bar.out')];
await buildSystem.build(testWithoutContextTarget2, environment);
expect(environment.buildDir.childFile('bar.out'), exists);
expect(environment.buildDir.childFile('foo.out'), isNot(exists));
});
testWithoutContext('Reruns build if stamp is corrupted', () async {
final BuildSystem buildSystem = setUpBuildSystem(fileSystem);
final TestTarget testWithoutContextTarget = TestTarget((Environment environment) async {
environment.buildDir.childFile('foo.out').createSync();
})
..inputs = const <Source>[Source.pattern('{PROJECT_DIR}/foo.dart')]
..outputs = const <Source>[Source.pattern('{BUILD_DIR}/foo.out')];
fileSystem.file('foo.dart').createSync();
await buildSystem.build(testWithoutContextTarget, environment);
// invalid JSON
environment.buildDir.childFile('testWithoutContext.stamp').writeAsStringSync('{X');
await buildSystem.build(testWithoutContextTarget, environment);
// empty file
environment.buildDir.childFile('testWithoutContext.stamp').writeAsStringSync('');
await buildSystem.build(testWithoutContextTarget, environment);
// invalid format
environment.buildDir.childFile('testWithoutContext.stamp').writeAsStringSync('{"inputs": 2, "outputs": 3}');
await buildSystem.build(testWithoutContextTarget, environment);
});
testWithoutContext('handles a throwing build action without crashing', () async {
final BuildSystem buildSystem = setUpBuildSystem(fileSystem);
final BuildResult result = await buildSystem.build(fizzTarget, environment);
expect(result.hasException, true);
});
testWithoutContext('Can describe itself with JSON output', () {
environment.buildDir.createSync(recursive: true);
expect(fooTarget.toJson(environment), <String, Object?>{
'inputs': <Object>[
'/foo.dart',
],
'outputs': <Object>[
fileSystem.path.join(environment.buildDir.path, 'out'),
],
'dependencies': <Object>[],
'name': 'foo',
'stamp': fileSystem.path.join(environment.buildDir.path, 'foo.stamp'),
});
});
testWithoutContext('Can find dependency cycles', () {
final Target barTarget = TestTarget()..name = 'bar';
final Target fooTarget = TestTarget()..name = 'foo';
barTarget.dependencies.add(fooTarget);
fooTarget.dependencies.add(barTarget);
expect(() => checkCycles(barTarget), throwsA(isA<CycleException>()));
});
testWithoutContext('Target with depfile dependency will not run twice without invalidation', () async {
final BuildSystem buildSystem = setUpBuildSystem(fileSystem);
int called = 0;
final TestTarget target = TestTarget((Environment environment) async {
environment.buildDir
.childFile('example.d')
.writeAsStringSync('a.txt: b.txt');
fileSystem.file('a.txt').writeAsStringSync('a');
called += 1;
})
..depfiles = <String>['example.d'];
fileSystem.file('b.txt').writeAsStringSync('b');
await buildSystem.build(target, environment);
expect(fileSystem.file('a.txt'), exists);
expect(called, 1);
// Second build is up to date due to depfile parse.
await buildSystem.build(target, environment);
expect(called, 1);
});
testWithoutContext('Target with depfile dependency will not run twice without '
'invalidation in incremental builds', () async {
final BuildSystem buildSystem = setUpBuildSystem(fileSystem);
int called = 0;
final TestTarget target = TestTarget((Environment environment) async {
environment.buildDir
.childFile('example.d')
.writeAsStringSync('a.txt: b.txt');
fileSystem.file('a.txt').writeAsStringSync('a');
called += 1;
})
..depfiles = <String>['example.d'];
fileSystem.file('b.txt').writeAsStringSync('b');
final BuildResult result = await buildSystem
.buildIncremental(target, environment, null);
expect(fileSystem.file('a.txt'), exists);
expect(called, 1);
// Second build is up to date due to depfile parse.
await buildSystem.buildIncremental(target, environment, result);
expect(called, 1);
});
testWithoutContext('output directory is an input to the build', () async {
final Environment environmentA = Environment.test(
fileSystem.currentDirectory,
outputDir: fileSystem.directory('a'),
artifacts: Artifacts.test(),
processManager: FakeProcessManager.any(),
fileSystem: fileSystem,
logger: BufferLogger.test(),
);
final Environment environmentB = Environment.test(
fileSystem.currentDirectory,
outputDir: fileSystem.directory('b'),
artifacts: Artifacts.test(),
processManager: FakeProcessManager.any(),
fileSystem: fileSystem,
logger: BufferLogger.test(),
);
expect(environmentA.buildDir.path, isNot(environmentB.buildDir.path));
});
testWithoutContext('Additional inputs do not change the build configuration', () async {
final Environment environmentA = Environment.test(
fileSystem.currentDirectory,
artifacts: Artifacts.test(),
processManager: FakeProcessManager.any(),
fileSystem: fileSystem,
logger: BufferLogger.test(),
inputs: <String, String>{
'C': 'D',
}
);
final Environment environmentB = Environment.test(
fileSystem.currentDirectory,
artifacts: Artifacts.test(),
processManager: FakeProcessManager.any(),
fileSystem: fileSystem,
logger: BufferLogger.test(),
inputs: <String, String>{
'A': 'B',
}
);
expect(environmentA.buildDir.path, equals(environmentB.buildDir.path));
});
testWithoutContext('A target with depfile dependencies can delete stale outputs on the first run', () async {
final BuildSystem buildSystem = setUpBuildSystem(fileSystem);
int called = 0;
final TestTarget target = TestTarget((Environment environment) async {
if (called == 0) {
environment.buildDir.childFile('example.d')
.writeAsStringSync('a.txt c.txt: b.txt');
fileSystem.file('a.txt').writeAsStringSync('a');
fileSystem.file('c.txt').writeAsStringSync('a');
} else {
// On second run, we no longer claim c.txt as an output.
environment.buildDir.childFile('example.d')
.writeAsStringSync('a.txt: b.txt');
fileSystem.file('a.txt').writeAsStringSync('a');
}
called += 1;
})
..depfiles = const <String>['example.d'];
fileSystem.file('b.txt').writeAsStringSync('b');
await buildSystem.build(target, environment);
expect(fileSystem.file('a.txt'), exists);
expect(fileSystem.file('c.txt'), exists);
expect(called, 1);
// rewrite an input to force a rerun, expect that the old c.txt is deleted.
fileSystem.file('b.txt').writeAsStringSync('ba');
await buildSystem.build(target, environment);
expect(fileSystem.file('a.txt'), exists);
expect(fileSystem.file('c.txt'), isNot(exists));
expect(called, 2);
});
testWithoutContext('trackSharedBuildDirectory handles a missing .last_build_id', () {
FlutterBuildSystem(
fileSystem: fileSystem,
logger: BufferLogger.test(),
platform: FakePlatform(),
).trackSharedBuildDirectory(environment, fileSystem, <String, File>{});
expect(environment.outputDir.childFile('.last_build_id'), exists);
expect(environment.outputDir.childFile('.last_build_id').readAsStringSync(),
'6666cd76f96956469e7be39d750cc7d9');
});
testWithoutContext('trackSharedBuildDirectory handles a missing output dir', () {
final Environment environment = Environment.test(
fileSystem.currentDirectory,
outputDir: fileSystem.directory('a/b/c/d'),
artifacts: Artifacts.test(),
processManager: FakeProcessManager.any(),
fileSystem: fileSystem,
logger: BufferLogger.test(),
);
FlutterBuildSystem(
fileSystem: fileSystem,
logger: BufferLogger.test(),
platform: FakePlatform(),
).trackSharedBuildDirectory(environment, fileSystem, <String, File>{});
expect(environment.outputDir.childFile('.last_build_id'), exists);
expect(environment.outputDir.childFile('.last_build_id').readAsStringSync(),
'5954e2278dd01e1c4e747578776eeb94');
});
testWithoutContext('trackSharedBuildDirectory does not modify .last_build_id when config is identical', () {
environment.outputDir.childFile('.last_build_id')
..writeAsStringSync('6666cd76f96956469e7be39d750cc7d9')
..setLastModifiedSync(DateTime(1991, 8, 23));
FlutterBuildSystem(
fileSystem: fileSystem,
logger: BufferLogger.test(),
platform: FakePlatform(),
).trackSharedBuildDirectory(environment, fileSystem, <String, File>{});
expect(environment.outputDir.childFile('.last_build_id').lastModifiedSync(),
DateTime(1991, 8, 23));
});
testWithoutContext('trackSharedBuildDirectory does not delete files when outputs.json is missing', () {
environment.outputDir
.childFile('.last_build_id')
.writeAsStringSync('foo');
environment.buildDir.parent
.childDirectory('foo')
.createSync(recursive: true);
environment.outputDir
.childFile('stale')
.createSync();
FlutterBuildSystem(
fileSystem: fileSystem,
logger: BufferLogger.test(),
platform: FakePlatform(),
).trackSharedBuildDirectory(environment, fileSystem, <String, File>{});
expect(environment.outputDir.childFile('.last_build_id').readAsStringSync(),
'6666cd76f96956469e7be39d750cc7d9');
expect(environment.outputDir.childFile('stale'), exists);
});
testWithoutContext('trackSharedBuildDirectory deletes files in outputs.json but not in current outputs', () {
environment.outputDir
.childFile('.last_build_id')
.writeAsStringSync('foo');
final Directory otherBuildDir = environment.buildDir.parent
.childDirectory('foo')
..createSync(recursive: true);
final File staleFile = environment.outputDir
.childFile('stale')
..createSync();
otherBuildDir.childFile('outputs.json')
.writeAsStringSync(json.encode(<String>[staleFile.absolute.path]));
FlutterBuildSystem(
fileSystem: fileSystem,
logger: BufferLogger.test(),
platform: FakePlatform(),
).trackSharedBuildDirectory(environment, fileSystem, <String, File>{});
expect(environment.outputDir.childFile('.last_build_id').readAsStringSync(),
'6666cd76f96956469e7be39d750cc7d9');
expect(environment.outputDir.childFile('stale'), isNot(exists));
});
testWithoutContext('multiple builds to the same output directory do no leave stale artifacts', () async {
final BuildSystem buildSystem = setUpBuildSystem(fileSystem);
final Environment testEnvironmentDebug = Environment.test(
fileSystem.currentDirectory,
outputDir: fileSystem.directory('output'),
defines: <String, String>{
'config': 'debug',
},
artifacts: Artifacts.test(),
processManager: FakeProcessManager.any(),
logger: BufferLogger.test(),
fileSystem: fileSystem,
);
final Environment testEnvironmentProfile = Environment.test(
fileSystem.currentDirectory,
outputDir: fileSystem.directory('output'),
defines: <String, String>{
'config': 'profile',
},
artifacts: Artifacts.test(),
processManager: FakeProcessManager.any(),
logger: BufferLogger.test(),
fileSystem: fileSystem,
);
final TestTarget debugTarget = TestTarget((Environment environment) async {
environment.outputDir.childFile('debug').createSync();
})..outputs = const <Source>[Source.pattern('{OUTPUT_DIR}/debug')];
final TestTarget releaseTarget = TestTarget((Environment environment) async {
environment.outputDir.childFile('release').createSync();
})..outputs = const <Source>[Source.pattern('{OUTPUT_DIR}/release')];
await buildSystem.build(debugTarget, testEnvironmentDebug);
// Verify debug output was created
expect(fileSystem.file('output/debug'), exists);
await buildSystem.build(releaseTarget, testEnvironmentProfile);
// Last build config is updated properly
expect(testEnvironmentProfile.outputDir.childFile('.last_build_id'), exists);
expect(testEnvironmentProfile.outputDir.childFile('.last_build_id').readAsStringSync(),
'c20b3747fb2aa148cc4fd39bfbbd894f');
// Verify debug output removed
expect(fileSystem.file('output/debug'), isNot(exists));
expect(fileSystem.file('output/release'), exists);
});
testWithoutContext('A target using canSkip can create a conditional output', () async {
final BuildSystem buildSystem = setUpBuildSystem(fileSystem);
final File bar = environment.buildDir.childFile('bar');
final File foo = environment.buildDir.childFile('foo');
// The target will write a file `foo`, but only if `bar` already exists.
final TestTarget target = TestTarget(
(Environment environment) async {
foo.writeAsStringSync(bar.readAsStringSync());
environment.buildDir
.childFile('example.d')
.writeAsStringSync('${foo.path}: ${bar.path}');
},
(Environment environment) {
return !environment.buildDir.childFile('bar').existsSync();
}
)
..depfiles = const <String>['example.d'];
// bar does not exist, there should be no inputs/outputs.
final BuildResult firstResult = await buildSystem.build(target, environment);
expect(foo, isNot(exists));
expect(firstResult.inputFiles, isEmpty);
expect(firstResult.outputFiles, isEmpty);
// bar is created, the target should be able to run.
bar.writeAsStringSync('content-1');
final BuildResult secondResult = await buildSystem.build(target, environment);
expect(foo, exists);
expect(secondResult.inputFiles.map((File file) => file.path), <String>[bar.path]);
expect(secondResult.outputFiles.map((File file) => file.path), <String>[foo.path]);
// bar is destroyed, foo is also deleted.
bar.deleteSync();
final BuildResult thirdResult = await buildSystem.build(target, environment);
expect(foo, isNot(exists));
expect(thirdResult.inputFiles, isEmpty);
expect(thirdResult.outputFiles, isEmpty);
});
testWithoutContext('Build completes all dependencies before failing', () async {
final MemoryFileSystem fileSystem = MemoryFileSystem.test();
final BuildSystem buildSystem = setUpBuildSystem(fileSystem, FakePlatform(
numberOfProcessors: 10, // Ensure the tool will process tasks concurrently.
));
final Completer<void> startB = Completer<void>();
final Completer<void> startC = Completer<void>();
final Completer<void> finishB = Completer<void>();
final TestTarget a = TestTarget((Environment environment) {
throw StateError('Should not run');
})..name = 'A';
final TestTarget b = TestTarget((Environment environment) async {
startB.complete();
await finishB.future;
throw Exception('1');
})..name = 'B';
final TestTarget c = TestTarget((Environment environment) {
startC.complete();
throw Exception('2');
})..name = 'C';
a.dependencies.addAll(<Target>[b, c]);
final Future<BuildResult> pendingResult = buildSystem.build(a, environment);
await startB.future;
await startC.future;
finishB.complete();
final BuildResult result = await pendingResult;
expect(result.success, false);
expect(result.exceptions.keys, containsAll(<String>['B', 'C']));
});
}
BuildSystem setUpBuildSystem(FileSystem fileSystem, [FakePlatform? platform]) {
return FlutterBuildSystem(
fileSystem: fileSystem,
logger: BufferLogger.test(),
platform: platform ?? FakePlatform(),
);
}
class TestTarget extends Target {
TestTarget([Future<void> Function(Environment environment)? build, this._canSkip])
: _build = build ?? ((Environment environment) async {});
final Future<void> Function(Environment environment) _build;
final bool Function(Environment environment)? _canSkip;
@override
bool canSkip(Environment environment) {
if (_canSkip != null) {
return _canSkip!(environment);
}
return super.canSkip(environment);
}
@override
Future<void> build(Environment environment) => _build(environment);
@override
List<Target> dependencies = <Target>[];
@override
List<Source> inputs = <Source>[];
@override
List<String> depfiles = <String>[];
@override
String name = 'test';
@override
List<Source> outputs = <Source>[];
}