/* * Copyright 2012-present Facebook, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); you may * not use this file except in compliance with the License. You may obtain * a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the * License for the specific language governing permissions and limitations * under the License. */ package com.facebook.buck.testutil.integration; import static java.nio.charset.StandardCharsets.UTF_8; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; import static org.junit.Assume.assumeTrue; import com.dd.plist.BinaryPropertyListParser; import com.dd.plist.NSDictionary; import com.dd.plist.NSObject; import com.facebook.buck.android.DefaultAndroidDirectoryResolver; import com.facebook.buck.cli.BuckConfig; import com.facebook.buck.cli.Main; import com.facebook.buck.cli.TestRunning; import com.facebook.buck.config.CellConfig; import com.facebook.buck.config.Config; import com.facebook.buck.config.Configs; import com.facebook.buck.io.BuckPaths; import com.facebook.buck.io.ExecutableFinder; import com.facebook.buck.io.MoreFiles; import com.facebook.buck.io.MorePaths; import com.facebook.buck.io.ProjectFilesystem; import com.facebook.buck.io.Watchman; import com.facebook.buck.jvm.java.JavaCompilationConstants; import com.facebook.buck.model.BuckVersion; import com.facebook.buck.model.BuildId; import com.facebook.buck.model.BuildTarget; import com.facebook.buck.model.BuildTargetFactory; import com.facebook.buck.rules.Cell; import com.facebook.buck.rules.CellProvider; import com.facebook.buck.rules.DefaultCellPathResolver; import com.facebook.buck.rules.KnownBuildRuleTypesFactory; import com.facebook.buck.testutil.TestConsole; import com.facebook.buck.util.BuckConstant; import com.facebook.buck.util.CapturingPrintStream; import com.facebook.buck.util.DefaultProcessExecutor; import com.facebook.buck.util.MoreCollectors; import com.facebook.buck.util.MoreStrings; import com.facebook.buck.util.ProcessExecutor; import com.facebook.buck.util.ProcessExecutorParams; import com.facebook.buck.util.WatchmanWatcher; import com.facebook.buck.util.environment.Architecture; import com.facebook.buck.util.environment.CommandMode; import com.facebook.buck.util.environment.Platform; import com.facebook.buck.util.trace.ChromeTraceParser; import com.facebook.buck.util.trace.ChromeTraceParser.ChromeTraceEventMatcher; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.CharMatcher; import com.google.common.base.Charsets; import com.google.common.base.Function; import com.google.common.base.Preconditions; import com.google.common.base.Splitter; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import com.martiansoftware.nailgun.NGContext; import java.io.BufferedOutputStream; import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.channels.Channels; import java.nio.file.FileAlreadyExistsException; import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.NoSuchFileException; import java.nio.file.OpenOption; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.SimpleFileVisitor; import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.nio.file.attribute.BasicFileAttributes; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import javax.annotation.Nullable; import org.hamcrest.Matchers; /** * {@link ProjectWorkspace} is a directory that contains a Buck project, complete with build files. * * <p>When {@link #setUp()} is invoked, the project files are cloned from a directory of testdata * into a tmp directory according to the following rule: * * <ul> * <li>Files with the {@code .fixture} extension will be copied and renamed without the extension. * <li>Files with the {@code .expected} extension will not be copied. * </ul> * * After {@link #setUp()} is invoked, the test should invoke Buck in that directory. As this is an * integration test, we expect that files will be written as a result of invoking Buck. * * <p>After Buck has been run, invoke {@link #verify()} to verify that Buck wrote the correct files. * For each file in the testdata directory with the {@code .expected} extension, {@link #verify()} * will check that a file with the same relative path (but without the {@code .expected} extension) * exists in the tmp directory. If not, {@link org.junit.Assert#fail()} will be invoked. */ public class ProjectWorkspace { private static final String FIXTURE_SUFFIX = "fixture"; private static final String EXPECTED_SUFFIX = "expected"; private static final String PATH_TO_BUILD_LOG = "buck-out/bin/build.log"; private static final Function<Path, Path> BUILD_FILE_RENAME = new Function<Path, Path>() { @Override @Nullable public Path apply(Path path) { String fileName = path.getFileName().toString(); String extension = com.google.common.io.Files.getFileExtension(fileName); switch (extension) { case FIXTURE_SUFFIX: return path.getParent() .resolve(com.google.common.io.Files.getNameWithoutExtension(fileName)); case EXPECTED_SUFFIX: return null; default: return path; } } }; private boolean isSetUp = false; private boolean manageLocalConfigs = false; private final Map<String, Map<String, String>> localConfigs = new HashMap<>(); private final Path templatePath; private final Path destPath; @Nullable private ProjectFilesystemAndConfig projectFilesystemAndConfig; private static class ProjectFilesystemAndConfig { private final ProjectFilesystem projectFilesystem; private final Config config; private ProjectFilesystemAndConfig(ProjectFilesystem projectFilesystem, Config config) { this.projectFilesystem = projectFilesystem; this.config = config; } } @VisibleForTesting ProjectWorkspace(Path templateDir, final Path targetFolder) { this.templatePath = templateDir; this.destPath = targetFolder; } /** * This will copy the template directory, renaming files named {@code foo.fixture} to {@code foo} * in the process. Files whose names end in {@code .expected} will not be copied. */ @SuppressWarnings("PMD.EmptyCatchBlock") public ProjectWorkspace setUp() throws IOException { MoreFiles.copyRecursively(templatePath, destPath, BUILD_FILE_RENAME); // Stamp the buck-out directory if it exists and isn't stamped already try (OutputStream outputStream = new BufferedOutputStream( Channels.newOutputStream( Files.newByteChannel( destPath.resolve(BuckConstant.getBuckOutputPath().resolve(".currentversion")), ImmutableSet.<OpenOption>of( StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE))))) { outputStream.write(BuckVersion.getVersion().getBytes(Charsets.UTF_8)); } catch (FileAlreadyExistsException | NoSuchFileException e) { // If the current version file already exists we don't need to create it // If buck-out doesn't exist we don't need to stamp it } if (Platform.detect() == Platform.WINDOWS) { // Hack for symlinks on Windows. SimpleFileVisitor<Path> copyDirVisitor = new SimpleFileVisitor<Path>() { @Override public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) throws IOException { // On Windows, symbolic links from git repository are checked out as normal files // containing a one-line path. In order to distinguish them, paths are read and pointed // files are trued to locate. Once the pointed file is found, it will be copied to target. // On NTFS length of path must be greater than 0 and less than 4096. if (attrs.size() > 0 && attrs.size() <= 4096) { String linkTo = new String(Files.readAllBytes(path), UTF_8); Path linkToFile; try { linkToFile = templatePath.resolve(linkTo); } catch (InvalidPathException e) { // Let's assume we were reading a normal text file, and not something meant to be a // link. return FileVisitResult.CONTINUE; } if (Files.isRegularFile(linkToFile)) { Files.copy(linkToFile, path, StandardCopyOption.REPLACE_EXISTING); } else if (Files.isDirectory(linkToFile)) { Files.delete(path); MoreFiles.copyRecursively(linkToFile, path); } } return FileVisitResult.CONTINUE; } }; Files.walkFileTree(destPath, copyDirVisitor); } if (!Files.exists(getPath(".buckconfig.local"))) { manageLocalConfigs = true; // Enable the JUL build log. This log is very verbose but rarely useful, // so it's disabled by default. addBuckConfigLocalOption("log", "jul_build_log", "true"); // Disable the directory cache by default. Tests that want to enable it can call // `enableDirCache` on this object. Only do this if a .buckconfig.local file does not already // exist, however (we assume the test knows what it is doing at that point). addBuckConfigLocalOption("cache", "mode", ""); // Limit the number of threads by default to prevent multiple integration tests running at the // same time from creating a quadratic number of threads. Tests can disable this using // `disableThreadLimitOverride`. addBuckConfigLocalOption("build", "threads", "2"); } // We have to have .watchmanconfig on windows, otherwise we have problems with deleting stuff // from buck-out while watchman indexes/touches files. if (!Files.exists(getPath(".watchmanconfig"))) { writeContentsToPath("{\"ignore_dirs\":[\"buck-out\",\".buckd\"]}", ".watchmanconfig"); } isSetUp = true; return this; } private Map<String, String> getBuckConfigLocalSection(String section) { Map<String, String> newValue = new HashMap<>(); Map<String, String> oldValue = localConfigs.putIfAbsent(section, newValue); if (oldValue != null) { return oldValue; } else { return newValue; } } public void addBuckConfigLocalOption(String section, String key, String value) throws IOException { getBuckConfigLocalSection(section).put(key, value); saveBuckConfigLocal(); } public void removeBuckConfigLocalOption(String section, String key) throws IOException { getBuckConfigLocalSection(section).remove(key); saveBuckConfigLocal(); } private void saveBuckConfigLocal() throws IOException { Preconditions.checkArgument( manageLocalConfigs, "ProjectWorkspace cannot modify .buckconfig.local because " + "a custom one is already present in the test data directory"); StringBuilder contents = new StringBuilder(); for (Map.Entry<String, Map<String, String>> section : localConfigs.entrySet()) { contents.append("[").append(section.getKey()).append("]\n\n"); for (Map.Entry<String, String> option : section.getValue().entrySet()) { contents.append(option.getKey()).append(" = ").append(option.getValue()).append("\n"); } contents.append("\n"); } writeContentsToPath(contents.toString(), ".buckconfig.local"); } private ProjectFilesystemAndConfig getProjectFilesystemAndConfig() throws InterruptedException, IOException { if (projectFilesystemAndConfig == null) { Config config = Configs.createDefaultConfig(destPath); projectFilesystemAndConfig = new ProjectFilesystemAndConfig(new ProjectFilesystem(destPath, config), config); } return projectFilesystemAndConfig; } public BuckPaths getBuckPaths() throws InterruptedException, IOException { return getProjectFilesystemAndConfig().projectFilesystem.getBuckPaths(); } public ProcessResult runBuckBuild(String... args) throws IOException { String[] totalArgs = new String[args.length + 1]; totalArgs[0] = "build"; System.arraycopy(args, 0, totalArgs, 1, args.length); return runBuckCommand(totalArgs); } public ProcessResult runBuckDistBuildRun(String... args) throws IOException { String[] totalArgs = new String[args.length + 2]; totalArgs[0] = "distbuild"; totalArgs[1] = "run"; System.arraycopy(args, 0, totalArgs, 2, args.length); return runBuckCommand(totalArgs); } private ImmutableMap<String, String> buildMultipleAndReturnStringOutputs(String... args) throws IOException { // Add in `--show-output` to the build, so we can parse the output paths after the fact. ImmutableList<String> buildArgs = ImmutableList.<String>builder().add("--show-output").add(args).build(); ProjectWorkspace.ProcessResult buildResult = runBuckBuild(buildArgs.toArray(new String[buildArgs.size()])); buildResult.assertSuccess(); // Grab the stdout lines, which have the build outputs. List<String> lines = Splitter.on(CharMatcher.anyOf(System.lineSeparator())) .trimResults() .omitEmptyStrings() .splitToList(buildResult.getStdout()); // Skip the first line, which is just "The outputs are:". assertThat(lines.get(0), Matchers.equalTo("The outputs are:")); lines = lines.subList(1, lines.size()); Splitter lineSplitter = Splitter.on(' ').trimResults(); ImmutableMap.Builder<String, String> builder = ImmutableMap.builder(); for (String line : lines) { List<String> fields = lineSplitter.splitToList(line); assertThat(fields, Matchers.hasSize(2)); builder.put(fields.get(0), fields.get(1)); } return builder.build(); } public ImmutableMap<String, Path> buildMultipleAndReturnOutputs(String... args) throws IOException { return buildMultipleAndReturnStringOutputs(args) .entrySet() .stream() .collect( MoreCollectors.toImmutableMap( entry -> entry.getKey(), entry -> getPath(entry.getValue()))); } public Path buildAndReturnOutput(String... args) throws IOException { ImmutableMap<String, Path> outputs = buildMultipleAndReturnOutputs(args); // Verify we only have a single output. assertThat( String.format( "expected only a single build target in command `%s`: %s", ImmutableList.copyOf(args), outputs), outputs.entrySet(), Matchers.hasSize(1)); return outputs.values().iterator().next(); } public ImmutableMap<String, Path> buildMultipleAndReturnRelativeOutputs(String... args) throws IOException { return buildMultipleAndReturnStringOutputs(args) .entrySet() .stream() .collect( MoreCollectors.toImmutableMap( entry -> entry.getKey(), entry -> Paths.get(entry.getValue()))); } public Path buildAndReturnRelativeOutput(String... args) throws IOException { ImmutableMap<String, Path> outputs = buildMultipleAndReturnRelativeOutputs(args); // Verify we only have a single output. assertThat( String.format( "expected only a single build target in command `%s`: %s", ImmutableList.copyOf(args), outputs), outputs.entrySet(), Matchers.hasSize(1)); return outputs.values().iterator().next(); } public ProcessExecutor.Result runJar(Path jar, ImmutableList<String> vmArgs, String... args) throws IOException, InterruptedException { List<String> command = ImmutableList.<String>builder() .add( JavaCompilationConstants.DEFAULT_JAVA_OPTIONS.getJavaRuntimeLauncher().getCommand()) .addAll(vmArgs) .add("-jar") .add(jar.toString()) .addAll(ImmutableList.copyOf(args)) .build(); return doRunCommand(command); } public ProcessExecutor.Result runJar(Path jar, String... args) throws IOException, InterruptedException { return runJar(jar, ImmutableList.of(), args); } public ProcessExecutor.Result runCommand(String exe, String... args) throws IOException, InterruptedException { List<String> command = ImmutableList.<String>builder().add(exe).addAll(ImmutableList.copyOf(args)).build(); return doRunCommand(command); } private ProcessExecutor.Result doRunCommand(List<String> command) throws IOException, InterruptedException { ProcessExecutorParams params = ProcessExecutorParams.builder() .setCommand(command) .setDirectory(destPath.toAbsolutePath()) .build(); ProcessExecutor executor = new DefaultProcessExecutor(new TestConsole()); return executor.launchAndExecute(params); } /** * Runs Buck with the specified list of command-line arguments. * * @param args to pass to {@code buck}, so that could be {@code ["build", "//path/to:target"]}, * {@code ["project"]}, etc. * @return the result of running Buck, which includes the exit code, stdout, and stderr. */ public ProcessResult runBuckCommand(String... args) throws IOException { return runBuckCommandWithEnvironmentOverridesAndContext( destPath, Optional.empty(), ImmutableMap.of(), args); } public ProcessResult runBuckCommand(ImmutableMap<String, String> environment, String... args) throws IOException { return runBuckCommandWithEnvironmentOverridesAndContext( destPath, Optional.empty(), environment, args); } public ProcessResult runBuckCommand(Path repoRoot, String... args) throws IOException { return runBuckCommandWithEnvironmentOverridesAndContext( repoRoot, Optional.empty(), ImmutableMap.of(), args); } public ProcessResult runBuckdCommand(String... args) throws IOException { try (TestContext context = new TestContext()) { return runBuckdCommand(context, args); } } public ProcessResult runBuckdCommand(ImmutableMap<String, String> environment, String... args) throws IOException { try (TestContext context = new TestContext(environment)) { return runBuckdCommand(context, args); } } public ProcessResult runBuckdCommand(NGContext context, String... args) throws IOException { return runBuckdCommand(context, new CapturingPrintStream(), args); } public ProcessResult runBuckdCommand( NGContext context, CapturingPrintStream stderr, String... args) throws IOException { assumeTrue( "watchman must exist to run buckd", new ExecutableFinder(Platform.detect()) .getOptionalExecutable(Paths.get("watchman"), ImmutableMap.copyOf(System.getenv())) .isPresent()); return runBuckCommandWithEnvironmentOverridesAndContext( destPath, Optional.of(context), ImmutableMap.of(), stderr, args); } public ProcessResult runBuckCommandWithEnvironmentOverridesAndContext( Path repoRoot, Optional<NGContext> context, ImmutableMap<String, String> environmentOverrides, String... args) throws IOException { return runBuckCommandWithEnvironmentOverridesAndContext( repoRoot, context, environmentOverrides, new CapturingPrintStream(), args); } public ProcessResult runBuckCommandWithEnvironmentOverridesAndContext( Path repoRoot, Optional<NGContext> context, ImmutableMap<String, String> environmentOverrides, CapturingPrintStream stderr, String... args) throws IOException { assertTrue("setUp() must be run before this method is invoked", isSetUp); CapturingPrintStream stdout = new CapturingPrintStream(); InputStream stdin = new ByteArrayInputStream("".getBytes()); // Construct a limited view of the parent environment for the child. // TODO(#5754812): we should eventually get tests working without requiring these be set. ImmutableList<String> inheritedEnvVars = ImmutableList.of( "ANDROID_HOME", "ANDROID_NDK", "ANDROID_NDK_REPOSITORY", "ANDROID_SDK", // TODO(grumpyjames) Write an equivalent of the groovyc and startGroovy // scripts provided by the groovy distribution in order to remove these two. "GROOVY_HOME", "JAVA_HOME", "NDK_HOME", "PATH", "PATHEXT", // Needed by ndk-build on Windows "OS", "ProgramW6432", "ProgramFiles(x86)", // The haskell integration tests call into GHC, which needs HOME to be set. "HOME", // TODO(#6586154): set TMP variable for ShellSteps "TMP"); Map<String, String> envBuilder = new HashMap<>(); for (String variable : inheritedEnvVars) { String value = System.getenv(variable); if (value != null) { envBuilder.put(variable, value); } } envBuilder.putAll(environmentOverrides); ImmutableMap<String, String> sanizitedEnv = ImmutableMap.copyOf(envBuilder); Main main = new Main(stdout, stderr, stdin); int exitCode; try { exitCode = main.runMainWithExitCode( new BuildId(), repoRoot, context, sanizitedEnv, CommandMode.TEST, WatchmanWatcher.FreshInstanceAction.NONE, System.nanoTime(), args); } catch (InterruptedException e) { e.printStackTrace(stderr); exitCode = Main.FAIL_EXIT_CODE; Thread.currentThread().interrupt(); } return new ProcessResult( exitCode, stdout.getContentsAsString(Charsets.UTF_8), stderr.getContentsAsString(Charsets.UTF_8)); } /** * Runs an event-driven parser on {@code buck-out/log/build.trace}, which is a symlink to the * trace of the most recent invocation of Buck (which may not have been a {@code buck build}). * * @see ChromeTraceParser#parse(Path, Set) */ public Map<ChromeTraceEventMatcher<?>, Object> parseTraceFromMostRecentBuckInvocation( Set<ChromeTraceEventMatcher<?>> matchers) throws InterruptedException, IOException { ProjectFilesystem projectFilesystem = getProjectFilesystemAndConfig().projectFilesystem; ChromeTraceParser parser = new ChromeTraceParser(projectFilesystem); return parser.parse( projectFilesystem.getBuckPaths().getLogDir().resolve("build.trace"), matchers); } public Path getDestPath() { return destPath; } public Path getPath(Path pathRelativeToProjectRoot) { return destPath.resolve(pathRelativeToProjectRoot); } public Path getPath(String pathRelativeToProjectRoot) { return destPath.resolve(pathRelativeToProjectRoot); } public String getFileContents(Path pathRelativeToProjectRoot) throws IOException { return getFileContentsWithAbsolutePath(getPath(pathRelativeToProjectRoot)); } public String getFileContents(String pathRelativeToProjectRoot) throws IOException { return getFileContentsWithAbsolutePath(getPath(pathRelativeToProjectRoot)); } private String getFileContentsWithAbsolutePath(Path path) throws IOException { String platformExt = null; switch (Platform.detect()) { case LINUX: platformExt = "linux"; break; case MACOS: platformExt = "macos"; break; case WINDOWS: platformExt = "win"; break; case FREEBSD: platformExt = "freebsd"; break; case UNKNOWN: // Leave platformExt as null. break; } if (platformExt != null) { String extension = com.google.common.io.Files.getFileExtension(path.toString()); String basename = com.google.common.io.Files.getNameWithoutExtension(path.toString()); Path platformPath = extension.length() > 0 ? path.getParent() .resolve(String.format("%s.%s.%s", basename, platformExt, extension)) : path.getParent().resolve(String.format("%s.%s", basename, platformExt)); if (platformPath.toFile().exists()) { path = platformPath; } } return new String(Files.readAllBytes(path), UTF_8); } public void enableDirCache() throws IOException { addBuckConfigLocalOption("cache", "mode", "dir"); } public void setupCxxSandboxing(boolean sandboxSources) throws IOException { addBuckConfigLocalOption("cxx", "sandbox_sources", Boolean.toString(sandboxSources)); } public void disableThreadLimitOverride() throws IOException { removeBuckConfigLocalOption("build", "threads"); } public void copyFile(String source, String dest) throws IOException { Path destination = getPath(dest); Files.deleteIfExists(destination); Files.copy(getPath(source), destination); } public void copyRecursively(Path source, Path pathRelativeToProjectRoot) throws IOException { MoreFiles.copyRecursively(source, destPath.resolve(pathRelativeToProjectRoot)); } public void move(String source, String dest) throws IOException { Files.move(getPath(source), getPath(dest)); } public void replaceFileContents( String pathRelativeToProjectRoot, String target, String replacement) throws IOException { String fileContents = getFileContents(pathRelativeToProjectRoot); fileContents = fileContents.replace(target, replacement); writeContentsToPath(fileContents, pathRelativeToProjectRoot); } public void writeContentsToPath( String contents, String pathRelativeToProjectRoot, OpenOption... options) throws IOException { Files.write(getPath(pathRelativeToProjectRoot), contents.getBytes(UTF_8), options); } /** @return the specified path resolved against the root of this workspace. */ public Path resolve(Path pathRelativeToWorkspaceRoot) { return destPath.resolve(pathRelativeToWorkspaceRoot); } public Path resolve(String pathRelativeToWorkspaceRoot) { return destPath.resolve(pathRelativeToWorkspaceRoot); } public void resetBuildLogFile() throws IOException { writeContentsToPath("", PATH_TO_BUILD_LOG); } public BuckBuildLog getBuildLog() throws IOException { return BuckBuildLog.fromLogContents( getDestPath(), Files.readAllLines(getPath(PATH_TO_BUILD_LOG), UTF_8)); } public Cell asCell() throws IOException, InterruptedException { ProjectFilesystemAndConfig filesystemAndConfig = getProjectFilesystemAndConfig(); ProjectFilesystem filesystem = filesystemAndConfig.projectFilesystem; Config config = filesystemAndConfig.config; TestConsole console = new TestConsole(); ImmutableMap<String, String> env = ImmutableMap.copyOf(System.getenv()); DefaultAndroidDirectoryResolver directoryResolver = new DefaultAndroidDirectoryResolver( filesystem.getRootPath().getFileSystem(), env, Optional.empty(), Optional.empty()); return CellProvider.createForLocalBuild( filesystem, Watchman.NULL_WATCHMAN, new BuckConfig( config, filesystem, Architecture.detect(), Platform.detect(), env, new DefaultCellPathResolver(filesystem.getRootPath(), config)), CellConfig.of(), new KnownBuildRuleTypesFactory(new DefaultProcessExecutor(console), directoryResolver)) .getCellByPath(filesystem.getRootPath()); } public BuildTarget newBuildTarget(String fullyQualifiedName) throws IOException, InterruptedException { return BuildTargetFactory.newInstance( asCell().getFilesystem().getRootPath(), fullyQualifiedName); } /** The result of running {@code buck} from the command line. */ public static class ProcessResult { private final int exitCode; private final String stdout; private final String stderr; private ProcessResult(int exitCode, String stdout, String stderr) { this.exitCode = exitCode; this.stdout = Preconditions.checkNotNull(stdout); this.stderr = Preconditions.checkNotNull(stderr); } /** * Returns the exit code from the process. * * <p>Currently, in practice, any time a client might want to use it, it is more appropriate to * use {@link #assertSuccess()} or {@link #assertFailure()} instead. */ public int getExitCode() { return exitCode; } public String getStdout() { return stdout; } public String getStderr() { return stderr; } public ProcessResult assertSuccess() { return assertExitCode(null, 0); } public ProcessResult assertSuccess(String message) { return assertExitCode(message, 0); } public ProcessResult assertFailure() { return assertExitCode(null, Main.FAIL_EXIT_CODE); } public ProcessResult assertTestFailure() { return assertExitCode(null, TestRunning.TEST_FAILURES_EXIT_CODE); } public ProcessResult assertTestFailure(String message) { return assertExitCode(message, TestRunning.TEST_FAILURES_EXIT_CODE); } public ProcessResult assertFailure(String message) { return assertExitCode(message, 1); } public ProcessResult assertExitCode(@Nullable String message, int exitCode) { if (exitCode == getExitCode()) { return this; } String failureMessage = String.format("Expected exit code %d but was %d.", exitCode, getExitCode()); if (message != null) { failureMessage = message + " " + failureMessage; } System.err.println("=== " + failureMessage + " ==="); System.err.println("=== STDERR ==="); System.err.println(getStderr()); System.err.println("=== STDOUT ==="); System.err.println(getStdout()); fail(failureMessage); return this; } public ProcessResult assertSpecialExitCode(String message, int exitCode) { return assertExitCode(message, exitCode); } } public void assertFilesEqual(Path expected, Path actual) throws IOException { if (!expected.isAbsolute()) { expected = templatePath.resolve(expected); } if (!actual.isAbsolute()) { actual = destPath.resolve(actual); } if (!Files.isRegularFile(actual)) { fail("Expected file " + actual + " could not be found."); } String extension = MorePaths.getFileExtension(actual); String cleanPathToObservedFile = MoreStrings.withoutSuffix(templatePath.relativize(expected).toString(), EXPECTED_SUFFIX); switch (extension) { // For Apple .plist and .stringsdict files, we define equivalence if: // 1. The two files are the same type (XML or binary) // 2. If binary: unserialized objects are deeply-equivalent. // Otherwise, fall back to exact string match. case "plist": case "stringsdict": NSObject expectedObject; try { expectedObject = BinaryPropertyListParser.parse(expected.toFile()); } catch (Exception e) { // Not binary format. expectedObject = null; } NSObject observedObject; try { observedObject = BinaryPropertyListParser.parse(actual.toFile()); } catch (Exception e) { // Not binary format. observedObject = null; } assertTrue( String.format( "In %s, expected plist to be of %s type.", cleanPathToObservedFile, (expectedObject != null) ? "binary" : "XML"), (expectedObject != null) == (observedObject != null)); if (expectedObject != null) { // These keys depend on the locally installed version of Xcode, so ignore them // in comparisons. String[] ignoredKeys = { "DTSDKName", "DTPlatformName", "DTPlatformVersion", "MinimumOSVersion", "DTSDKBuild", "DTPlatformBuild", "DTXcode", "DTXcodeBuild" }; if (observedObject instanceof NSDictionary && expectedObject instanceof NSDictionary) { for (String key : ignoredKeys) { ((NSDictionary) observedObject).remove(key); ((NSDictionary) expectedObject).remove(key); } } assertEquals( String.format( "In %s, expected binary plist contents to match.", cleanPathToObservedFile), expectedObject, observedObject); break; } else { assertFileContentsEqual(expected, actual); } break; default: assertFileContentsEqual(expected, actual); } } private void assertFileContentsEqual(Path expectedFile, Path observedFile) throws IOException { String cleanPathToObservedFile = MoreStrings.withoutSuffix( templatePath.relativize(expectedFile).toString(), EXPECTED_SUFFIX); String expectedFileContent = new String(Files.readAllBytes(expectedFile), UTF_8); String observedFileContent = new String(Files.readAllBytes(observedFile), UTF_8); // It is possible, on Windows, to have Git keep "\n"-style newlines, or convert them to // "\r\n"-style newlines. Support both ways by normalizing to "\n"-style newlines. // See https://help.github.com/articles/dealing-with-line-endings/ for more information. expectedFileContent = expectedFileContent.replace("\r\n", "\n"); observedFileContent = observedFileContent.replace("\r\n", "\n"); assertEquals( String.format( "In %s, expected content of %s to match that of %s.", cleanPathToObservedFile, expectedFileContent, observedFileContent), expectedFileContent, observedFileContent); } /** * For every file in the template directory whose name ends in {@code .expected}, checks that an * equivalent file has been written in the same place under the destination directory. * * @param templateSubdirectory An optional subdirectory to check. Only files in this directory * will be compared. */ private void assertPathsEqual(final Path templateSubdirectory, final Path destinationSubdirectory) throws IOException { SimpleFileVisitor<Path> copyDirVisitor = new SimpleFileVisitor<Path>() { @Override public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { String fileName = file.getFileName().toString(); if (fileName.endsWith(EXPECTED_SUFFIX)) { // Get File for the file that should be written, but without the ".expected" suffix. Path generatedFileWithSuffix = destinationSubdirectory.resolve(templateSubdirectory.relativize(file)); Path directory = generatedFileWithSuffix.getParent(); Path observedFile = directory.resolve(MorePaths.getNameWithoutExtension(file)); assertFilesEqual(file, observedFile); } return FileVisitResult.CONTINUE; } }; Files.walkFileTree(templateSubdirectory, copyDirVisitor); } public void verify(Path templateSubdirectory, Path destinationSubdirectory) throws IOException { assertPathsEqual( templatePath.resolve(templateSubdirectory), destPath.resolve(destinationSubdirectory)); } public void verify() throws IOException { assertPathsEqual(templatePath, destPath); } public void verify(Path subdirectory) throws IOException { Preconditions.checkArgument( !subdirectory.isAbsolute(), "'verify(subdirectory)' takes a relative path, but received '%s'", subdirectory); assertPathsEqual(templatePath.resolve(subdirectory), destPath.resolve(subdirectory)); } }