blob: 6c290ed793927ea157873ba40b9373ba02858258 [file] [log] [blame]
amirhed533e92018-08-01 17:03:47 -07001// Copyright 2018 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:async';
6import 'dart:io' as io;
7
8import 'package:file/file.dart';
9import 'package:file/local.dart';
10import 'package:platform/platform.dart';
11import 'package:process/process.dart';
12
13// If you are here trying to figure out how to use golden files in the Flutter
14// repo itself, consider reading this wiki page:
15// https://github.com/flutter/flutter/wiki/Writing-a-golden-file-test-for-package%3Aflutter
16
17const String _kFlutterRootKey = 'FLUTTER_ROOT';
18
19/// A class that represents a clone of the https://github.com/flutter/goldens
20/// repository, nested within the `bin/cache` directory of the caller's Flutter
21/// repository.
22class GoldensClient {
23 /// Create a handle to a local clone of the goldens repository.
24 GoldensClient({
25 this.fs = const LocalFileSystem(),
26 this.platform = const LocalPlatform(),
27 this.process = const LocalProcessManager(),
28 });
29
30 /// The file system to use for storing the local clone of the repository.
31 ///
32 /// This is useful in tests, where a local file system (the default) can
33 /// be replaced by a memory file system.
34 final FileSystem fs;
35
36 /// A wrapper for the [dart:io.Platform] API.
37 ///
38 /// This is useful in tests, where the system platform (the default) can
39 /// be replaced by a mock platform instance.
40 final Platform platform;
41
42 /// A controller for launching subprocesses.
43 ///
44 /// This is useful in tests, where the real process manager (the default)
45 /// can be replaced by a mock process manager that doesn't really create
46 /// subprocesses.
47 final ProcessManager process;
48
49 RandomAccessFile _lock;
50
51 /// The local [Directory] where the Flutter repository is hosted.
52 ///
53 /// Uses the [fs] file system.
54 Directory get flutterRoot => fs.directory(platform.environment[_kFlutterRootKey]);
55
56 /// The local [Directory] where the goldens repository is hosted.
57 ///
58 /// Uses the [fs] file system.
59 Directory get repositoryRoot => flutterRoot.childDirectory(fs.path.join('bin', 'cache', 'pkg', 'goldens'));
60
61 /// Prepares the local clone of the `flutter/goldens` repository for golden
62 /// file testing.
63 ///
64 /// This ensures that the goldens repository has been cloned into its
65 /// expected location within `bin/cache` and that it is synced to the Git
66 /// revision specified in `bin/internal/goldens.version`.
67 ///
68 /// While this is preparing the repository, it obtains a file lock such that
69 /// [GoldensClient] instances in other processes or isolates will not
70 /// duplicate the work that this is doing.
71 Future<void> prepare() async {
72 final String goldensCommit = await _getGoldensCommit();
73 String currentCommit = await _getCurrentCommit();
74 if (currentCommit != goldensCommit) {
75 await _obtainLock();
76 try {
77 // Check the current commit again now that we have the lock.
78 currentCommit = await _getCurrentCommit();
79 if (currentCommit != goldensCommit) {
80 if (currentCommit == null) {
81 await _initRepository();
82 }
Chris Bracken52e42142019-03-06 09:14:39 -080083 await _checkCanSync();
amirhed533e92018-08-01 17:03:47 -070084 await _syncTo(goldensCommit);
85 }
86 } finally {
87 await _releaseLock();
88 }
89 }
90 }
91
92 Future<String> _getGoldensCommit() async {
93 final File versionFile = flutterRoot.childFile(fs.path.join('bin', 'internal', 'goldens.version'));
94 return (await versionFile.readAsString()).trim();
95 }
96
97 Future<String> _getCurrentCommit() async {
98 if (!repositoryRoot.existsSync()) {
99 return null;
100 } else {
101 final io.ProcessResult revParse = await process.run(
102 <String>['git', 'rev-parse', 'HEAD'],
103 workingDirectory: repositoryRoot.path,
104 );
105 return revParse.exitCode == 0 ? revParse.stdout.trim() : null;
106 }
107 }
108
109 Future<void> _initRepository() async {
110 await repositoryRoot.create(recursive: true);
111 await _runCommands(
112 <String>[
113 'git init',
114 'git remote add upstream https://github.com/flutter/goldens.git',
115 'git remote set-url --push upstream git@github.com:flutter/goldens.git',
116 ],
117 workingDirectory: repositoryRoot,
118 );
119 }
120
Chris Bracken52e42142019-03-06 09:14:39 -0800121 Future<void> _checkCanSync() async {
122 final io.ProcessResult result = await process.run(
123 <String>['git', 'status', '--porcelain'],
124 workingDirectory: repositoryRoot.path,
125 );
126 if (result.stdout.trim().isNotEmpty) {
127 final StringBuffer buf = StringBuffer();
128 buf
129 ..writeln('flutter_goldens git checkout at ${repositoryRoot.path} has local changes and cannot be synced.')
130 ..writeln('To reset your client to a clean state, and lose any local golden test changes:')
131 ..writeln('cd ${repositoryRoot.path}')
132 ..writeln('git reset --hard HEAD')
133 ..writeln('git clean -x -d -f -f');
134 throw NonZeroExitCode(1, buf.toString());
135 }
136 }
137
amirhed533e92018-08-01 17:03:47 -0700138 Future<void> _syncTo(String commit) async {
139 await _runCommands(
140 <String>[
141 'git pull upstream master',
142 'git fetch upstream $commit',
143 'git reset --hard FETCH_HEAD',
144 ],
145 workingDirectory: repositoryRoot,
146 );
147 }
148
149 Future<void> _runCommands(
150 List<String> commands, {
151 Directory workingDirectory,
152 }) async {
153 for (String command in commands) {
154 final List<String> parts = command.split(' ');
155 final io.ProcessResult result = await process.run(
156 parts,
157 workingDirectory: workingDirectory?.path,
158 );
159 if (result.exitCode != 0) {
Alexandre Ardhuind927c932018-09-12 08:29:29 +0200160 throw NonZeroExitCode(result.exitCode, result.stderr);
amirhed533e92018-08-01 17:03:47 -0700161 }
162 }
163 }
164
165 Future<void> _obtainLock() async {
166 final File lockFile = flutterRoot.childFile(fs.path.join('bin', 'cache', 'goldens.lockfile'));
167 await lockFile.create(recursive: true);
168 _lock = await lockFile.open(mode: io.FileMode.write);
169 await _lock.lock(io.FileLock.blockingExclusive);
170 }
171
172 Future<void> _releaseLock() async {
173 await _lock.close();
174 _lock = null;
175 }
176}
177/// Exception that signals a process' exit with a non-zero exit code.
178class NonZeroExitCode implements Exception {
179 /// Create an exception that represents a non-zero exit code.
180 ///
181 /// The first argument must be non-zero.
182 const NonZeroExitCode(this.exitCode, this.stderr) : assert(exitCode != 0);
183
Chris Bracken52e42142019-03-06 09:14:39 -0800184 /// The code that the process will signal to the operating system.
amirhed533e92018-08-01 17:03:47 -0700185 ///
186 /// By definiton, this is not zero.
187 final int exitCode;
188
189 /// The message to show on standard error.
190 final String stderr;
191
192 @override
193 String toString() {
194 return 'Exit code $exitCode: $stderr';
195 }
196}