flutter_goldens.dart 9.32 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
// Copyright 2018 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 'dart:async';
import 'dart:io' as io;
import 'dart:typed_data';

import 'package:collection/collection.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:meta/meta.dart';
import 'package:platform/platform.dart';
import 'package:process/process.dart';

17 18 19 20
// If you are here trying to figure out how to use golden files in the Flutter
// repo itself, consider reading this wiki page:
// https://github.com/flutter/flutter/wiki/Writing-a-golden-file-test-for-package%3Aflutter

21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
const String _kFlutterRootKey = 'FLUTTER_ROOT';

/// Main method that can be used in a `flutter_test_config.dart` file to set
/// [goldenFileComparator] to an instance of [FlutterGoldenFileComparator] that
/// works for the current test.
Future<void> main(FutureOr<void> testMain()) async {
  goldenFileComparator = await FlutterGoldenFileComparator.fromDefaultComparator();
  await testMain();
}

/// A golden file comparator specific to the `flutter/flutter` repository.
///
/// Within the https://github.com/flutter/flutter repository, it's important
/// not to check-in binaries in order to keep the size of the repository to a
/// minimum. To satisfy this requirement, this comparator retrieves the golden
/// files from a sibling repository, `flutter/goldens`.
///
/// This comparator will locally clone the `flutter/goldens` repository into
/// the `$FLUTTER_ROOT/bin/cache/pkg/goldens` folder, then perform the comparison against
/// the files therein.
class FlutterGoldenFileComparator implements GoldenFileComparator {
42 43 44 45
  /// Creates a [FlutterGoldenFileComparator] that will resolve golden file
  /// URIs relative to the specified [basedir].
  ///
  /// The [fs] parameter exists for testing purposes only.
46 47 48 49 50 51
  @visibleForTesting
  FlutterGoldenFileComparator(
    this.basedir, {
    this.fs: const LocalFileSystem(),
  });

52
  /// The directory to which golden file URIs will be resolved in [compare] and [update].
53
  final Uri basedir;
54 55 56

  /// The file system used to perform file access.
  @visibleForTesting
57 58 59 60 61 62 63
  final FileSystem fs;

  /// Creates a new [FlutterGoldenFileComparator] that mirrors the relative
  /// path resolution of the default [goldenFileComparator].
  ///
  /// By the time the future completes, the clone of the `flutter/goldens`
  /// repository is guaranteed to be ready use.
64 65 66 67 68 69 70 71 72 73 74
  ///
  /// The [goldens] and [defaultComparator] parameters are visible for testing
  /// purposes only.
  static Future<FlutterGoldenFileComparator> fromDefaultComparator({
    GoldensClient goldens,
    LocalFileComparator defaultComparator,
  }) async {
    defaultComparator ??= goldenFileComparator;

    // Prepare the goldens repo.
    goldens ??= new GoldensClient();
75
    await goldens.prepare();
76 77 78 79 80 81

    // Calculate the appropriate basedir for the current test context.
    final FileSystem fs = goldens.fs;
    final Directory testDirectory = fs.directory(defaultComparator.basedir);
    final String testDirectoryRelativePath = fs.path.relative(testDirectory.path, from: goldens.flutterRoot.path);
    return new FlutterGoldenFileComparator(goldens.repositoryRoot.childDirectory(testDirectoryRelativePath).uri);
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102
  }

  @override
  Future<bool> compare(Uint8List imageBytes, Uri golden) async {
    final File goldenFile = _getGoldenFile(golden);
    if (!goldenFile.existsSync()) {
      throw new TestFailure('Could not be compared against non-existent file: "$golden"');
    }
    final List<int> goldenBytes = await goldenFile.readAsBytes();
    // TODO(tvolkert): Improve the intelligence of this comparison.
    return const ListEquality<int>().equals(goldenBytes, imageBytes);
  }

  @override
  Future<void> update(Uri golden, Uint8List imageBytes) async {
    final File goldenFile = _getGoldenFile(golden);
    await goldenFile.parent.create(recursive: true);
    await goldenFile.writeAsBytes(imageBytes, flush: true);
  }

  File _getGoldenFile(Uri uri) {
103
    return fs.directory(basedir).childFile(fs.file(uri).path);
104 105 106 107 108 109 110 111
  }
}

/// A class that represents a clone of the https://github.com/flutter/goldens
/// repository, nested within the `bin/cache` directory of the caller's Flutter
/// repository.
@visibleForTesting
class GoldensClient {
112
  /// Create a handle to a local clone of the goldens repository.
113 114 115 116 117 118
  GoldensClient({
    this.fs: const LocalFileSystem(),
    this.platform: const LocalPlatform(),
    this.process: const LocalProcessManager(),
  });

119 120 121 122
  /// The file system to use for storing the local clone of the repository.
  ///
  /// This is useful in tests, where a local file system (the default) can
  /// be replaced by a memory file system.
123
  final FileSystem fs;
124 125 126 127 128

  /// A wrapper for the [dart:io.Platform] API.
  ///
  /// This is useful in tests, where the system platform (the default) can
  /// be replaced by a mock platform instance.
129
  final Platform platform;
130 131 132 133 134 135

  /// A controller for launching subprocesses.
  ///
  /// This is useful in tests, where the real process manager (the default)
  /// can be replaced by a mock process manager that doesn't really create
  /// subprocesses.
136 137 138 139
  final ProcessManager process;

  RandomAccessFile _lock;

140 141 142
  /// The local [Directory] where the Flutter repository is hosted.
  ///
  /// Uses the [fs] file system.
143 144
  Directory get flutterRoot => fs.directory(platform.environment[_kFlutterRootKey]);

145 146 147
  /// The local [Directory] where the goldens repository is hosted.
  ///
  /// Uses the [fs] file system.
148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202
  Directory get repositoryRoot => flutterRoot.childDirectory(fs.path.join('bin', 'cache', 'pkg', 'goldens'));

  /// Prepares the local clone of the `flutter/goldens` repository for golden
  /// file testing.
  ///
  /// This ensures that the goldens repository has been cloned into its
  /// expected location within `bin/cache` and that it is synced to the Git
  /// revision specified in `bin/internal/goldens.version`.
  ///
  /// While this is preparing the repository, it obtains a file lock such that
  /// [GoldensClient] instances in other processes or isolates will not
  /// duplicate the work that this is doing.
  Future<void> prepare() async {
    final String goldensCommit = await _getGoldensCommit();
    String currentCommit = await _getCurrentCommit();
    if (currentCommit != goldensCommit) {
      await _obtainLock();
      try {
        // Check the current commit again now that we have the lock.
        currentCommit = await _getCurrentCommit();
        if (currentCommit != goldensCommit) {
          if (currentCommit == null) {
            await _initRepository();
          }
          await _syncTo(goldensCommit);
        }
      } finally {
        await _releaseLock();
      }
    }
  }

  Future<String> _getGoldensCommit() async {
    final File versionFile = flutterRoot.childFile(fs.path.join('bin', 'internal', 'goldens.version'));
    return (await versionFile.readAsString()).trim();
  }

  Future<String> _getCurrentCommit() async {
    if (!repositoryRoot.existsSync()) {
      return null;
    } else {
      final io.ProcessResult revParse = await process.run(
        <String>['git', 'rev-parse', 'HEAD'],
        workingDirectory: repositoryRoot.path,
      );
      return revParse.exitCode == 0 ? revParse.stdout.trim() : null;
    }
  }

  Future<void> _initRepository() async {
    await repositoryRoot.create(recursive: true);
    await _runCommands(
      <String>[
        'git init',
        'git remote add upstream https://github.com/flutter/goldens.git',
203
        'git remote set-url --push upstream git@github.com:flutter/goldens.git',
204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238
      ],
      workingDirectory: repositoryRoot,
    );
  }

  Future<void> _syncTo(String commit) async {
    await _runCommands(
      <String>[
        'git pull upstream master',
        'git fetch upstream $commit',
        'git reset --hard FETCH_HEAD',
      ],
      workingDirectory: repositoryRoot,
    );
  }

  Future<void> _runCommands(
    List<String> commands, {
    Directory workingDirectory,
  }) async {
    for (String command in commands) {
      final List<String> parts = command.split(' ');
      final io.ProcessResult result = await process.run(
        parts,
        workingDirectory: workingDirectory?.path,
      );
      if (result.exitCode != 0) {
        throw new NonZeroExitCode(result.exitCode, result.stderr);
      }
    }
  }

  Future<void> _obtainLock() async {
    final File lockFile = flutterRoot.childFile(fs.path.join('bin', 'cache', 'goldens.lockfile'));
    await lockFile.create(recursive: true);
239 240
    _lock = await lockFile.open(mode: io.FileMode.WRITE); // ignore: deprecated_member_use
    await _lock.lock(io.FileLock.BLOCKING_EXCLUSIVE); // ignore: deprecated_member_use
241 242 243 244 245 246 247 248 249 250
  }

  Future<void> _releaseLock() async {
    await _lock.close();
    _lock = null;
  }
}

/// Exception that signals a process' exit with a non-zero exit code.
class NonZeroExitCode implements Exception {
251 252 253
  /// Create an exception that represents a non-zero exit code.
  ///
  /// The first argument must be non-zero.
254 255
  const NonZeroExitCode(this.exitCode, this.stderr) : assert(exitCode != 0);

256 257 258
  /// The code that the process will signal to th eoperating system.
  ///
  /// By definiton, this is not zero.
259
  final int exitCode;
260 261

  /// The message to show on standard error.
262 263 264 265 266 267 268
  final String stderr;

  @override
  String toString() {
    return 'Exit code $exitCode: $stderr';
  }
}