E2E tests for user_build.py

These tests make sure that the flags that user_build.py resolves
for a given target matches that used during project generation
(as well as making sure that the template file is valid).

PiperOrigin-RevId: 204802803
diff --git a/src/TulsiGenerator/BazelAspectInfoExtractor.swift b/src/TulsiGenerator/BazelAspectInfoExtractor.swift
index ada235f..62ecf3f 100644
--- a/src/TulsiGenerator/BazelAspectInfoExtractor.swift
+++ b/src/TulsiGenerator/BazelAspectInfoExtractor.swift
@@ -199,6 +199,7 @@
     }
 
     let tulsiFlags = bazelSettingsProvider.tulsiFlags(hasSwift: hasSwift,
+                                                      options: nil,
                                                       features: features).getFlags(forDebug: isDbg)
     var arguments = startupOptions
     arguments.append(contentsOf: tulsiFlags.startup)
diff --git a/src/TulsiGenerator/BazelSettingsProvider.swift b/src/TulsiGenerator/BazelSettingsProvider.swift
index fcfdac0..5d1dd5d 100644
--- a/src/TulsiGenerator/BazelSettingsProvider.swift
+++ b/src/TulsiGenerator/BazelSettingsProvider.swift
@@ -101,7 +101,9 @@
 /// Defines an object that provides flags for Bazel invocations.
 protocol BazelSettingsProviderProtocol {
   /// All general-Tulsi flags, varying based on whether the project has Swift or not.
-  func tulsiFlags(hasSwift: Bool, features: Set<BazelSettingFeature>) -> BazelFlagsSet
+  func tulsiFlags(hasSwift: Bool,
+                  options: TulsiOptionSet?,
+                  features: Set<BazelSettingFeature>) -> BazelFlagsSet
 
   /// Bazel build settings, used during Xcode/user Bazel builds.
   func buildSettings(bazel: String,
@@ -169,10 +171,19 @@
     self.nonSwiftFlags = nonSwiftFlags
   }
 
-  func tulsiFlags(hasSwift: Bool, features: Set<BazelSettingFeature>) -> BazelFlagsSet {
+  func tulsiFlags(hasSwift: Bool,
+                  options: TulsiOptionSet?,
+                  features: Set<BazelSettingFeature>) -> BazelFlagsSet {
+    let optionFlags: BazelFlagsSet
+    if let options = options {
+      optionFlags = optionsBasedFlags(options)
+    } else {
+      optionFlags = BazelFlagsSet()
+    }
     let languageFlags = (hasSwift ? swiftFlags : nonSwiftFlags) + featureFlags(features,
                                                                                hasSwift: hasSwift)
-    return BazelFlagsSet(common: universalFlags) + cacheableFlags + nonCacheableFlags + languageFlags
+    return cacheableFlags + optionFlags + BazelFlagsSet(common: universalFlags) +
+      nonCacheableFlags + languageFlags
   }
 
   /// Non-cacheable Bazel flags based off of BazelSettingFeatures for the project.
diff --git a/src/TulsiGeneratorIntegrationTests/EndToEndGenerationTests.swift b/src/TulsiGeneratorIntegrationTests/EndToEndGenerationTests.swift
index d77337d..1746435 100644
--- a/src/TulsiGeneratorIntegrationTests/EndToEndGenerationTests.swift
+++ b/src/TulsiGeneratorIntegrationTests/EndToEndGenerationTests.swift
@@ -123,6 +123,8 @@
       return
     } catch Error.testSubdirectoryNotCreated {
       XCTFail("Failed to create output folder, aborting test.")
+    } catch Error.userBuildScriptInvocationFailure(let info) {
+      XCTFail("Failed to invoke user_build.py script. Context: \(info)")
     } catch let error {
       XCTFail("Unexpected failure: \(error)")
     }
@@ -169,6 +171,8 @@
                                               outputDir: "tulsi_e2e_output",
                                               options: projectOptions)
 
+    try validateBuildCommandForProject(projectURL, options: projectOptions, targets: [appLabel.value])
+
     let diffLines = diffProjectAt(projectURL, againstGoldenProject: projectName)
     validateDiff(diffLines)
   }
@@ -191,6 +195,7 @@
                                                             "bazel-genfiles/..."],
                                               additionalFilePaths: additionalFilePaths,
                                               outputDir: "tulsi_e2e_output")
+    try validateBuildCommandForProject(projectURL, swift: true, targets: [appLabel.value])
 
     let diffLines = diffProjectAt(projectURL, againstGoldenProject: projectName)
     validateDiff(diffLines)
@@ -215,6 +220,8 @@
                                               additionalFilePaths: additionalFilePaths,
                                               outputDir: "tulsi_e2e_output")
 
+    try validateBuildCommandForProject(projectURL, swift: true, targets: [appLabel.value])
+
     let diffLines = diffProjectAt(projectURL, againstGoldenProject: projectName)
     validateDiff(diffLines)
   }
@@ -238,6 +245,8 @@
                                               additionalFilePaths: additionalFilePaths,
                                               outputDir: "tulsi_e2e_output")
 
+    try validateBuildCommandForProject(projectURL, targets: [appLabel.value])
+
     let diffLines = diffProjectAt(projectURL, againstGoldenProject: projectName)
     validateDiff(diffLines)
   }
@@ -261,6 +270,8 @@
                                               additionalFilePaths: additionalFilePaths,
                                               outputDir: "tulsi_e2e_output")
 
+    try validateBuildCommandForProject(projectURL, targets: [appLabel.value])
+
     let diffLines = diffProjectAt(projectURL, againstGoldenProject: "SkylarkBundlingProject")
     validateDiff(diffLines)
   }
@@ -288,6 +299,8 @@
                                               additionalFilePaths: additionalFilePaths,
                                               outputDir: "tulsi_e2e_output")
 
+    try validateBuildCommandForProject(projectURL, targets: [appLabel.value])
+
     let diffLines = diffProjectAt(projectURL, againstGoldenProject: projectName)
     validateDiff(diffLines)
   }
@@ -321,6 +334,8 @@
                                               additionalFilePaths: additionalFilePaths,
                                               outputDir: "tulsi_e2e_output")
 
+    try validateBuildCommandForProject(projectURL, targets: [appLabel.value])
+
     let diffLines = diffProjectAt(projectURL, againstGoldenProject: projectName)
     validateDiff(diffLines)
   }
@@ -341,6 +356,8 @@
                                               additionalFilePaths: additionalFilePaths,
                                               outputDir: "tulsi_e2e_output")
 
+    try validateBuildCommandForProject(projectURL, targets: [appLabel.value])
+
     let diffLines = diffProjectAt(projectURL, againstGoldenProject: projectName)
     validateDiff(diffLines)
   }
diff --git a/src/TulsiGeneratorIntegrationTests/EndToEndIntegrationTestCase.swift b/src/TulsiGeneratorIntegrationTests/EndToEndIntegrationTestCase.swift
index 21619e9..8bca3c5 100644
--- a/src/TulsiGeneratorIntegrationTests/EndToEndIntegrationTestCase.swift
+++ b/src/TulsiGeneratorIntegrationTests/EndToEndIntegrationTestCase.swift
@@ -25,8 +25,12 @@
     case testSubdirectoryNotCreated
     /// The Xcode project could not be generated.
     case projectGenerationFailure(String)
+    /// Unable to execute user_build.py script.
+    case userBuildScriptInvocationFailure(String)
   }
 
+  let extraDebugFlags = ["--define=TULSI_TEST=dbg"]
+  let extraReleaseFlags = ["--define=TULSI_TEST=rel"]
   let fakeBazelURL = URL(fileURLWithPath: "/fake/tulsi_test_bazel", isDirectory: false)
   let testTulsiVersion = "9.99.999.9999"
 
@@ -96,6 +100,112 @@
     }
   }
 
+  final func validateBuildCommandForProject(_ projectURL: URL,
+                                            swift: Bool = false,
+                                            options: TulsiOptionSet = TulsiOptionSet(),
+                                            targets: [String]) throws {
+    let actualDebug = try userBuildCommandForProject(projectURL, release: false, targets: targets)
+    let actualRelease = try userBuildCommandForProject(projectURL, release: true, targets: targets)
+    let (debug, release) = expectedBuildCommands(swift: swift, options: options, targets: targets)
+
+    XCTAssertEqual(actualDebug, debug)
+    XCTAssertEqual(actualRelease, release)
+  }
+
+  final func expectedBuildCommands(swift: Bool,
+                                   options: TulsiOptionSet,
+                                   targets: [String]) -> (String, String) {
+    let provider = BazelSettingsProvider(universalFlags: bazelUniversalFlags)
+    let execRoot = workspaceInfoFetcher.getExecutionRoot()
+    let features = BazelBuildSettingsFeatures.enabledFeatures(options: options,
+                                                              workspaceRoot: workspaceRootURL.path,
+                                                              bazelExecRoot: execRoot)
+    let dbg = provider.tulsiFlags(hasSwift: swift, options: options, features: features).debug
+    let rel = provider.tulsiFlags(hasSwift: swift, options: options, features: features).release
+
+    let config: PlatformConfiguration
+    if let identifier = options[.ProjectGenerationPlatformConfiguration].commonValue,
+      let parsedConfig = PlatformConfiguration(identifier: identifier) {
+      config = parsedConfig
+    } else {
+      config = PlatformConfiguration.defaultConfiguration
+    }
+
+    func buildCommand(extraBuildFlags: [String], tulsiFlags: BazelFlags) -> String {
+      var args = [fakeBazelURL.path]
+      args.append(contentsOf: bazelStartupOptions)
+      args.append(contentsOf: tulsiFlags.startup)
+      args.append("build")
+      args.append(contentsOf: extraBuildFlags)
+      args.append(contentsOf: bazelBuildOptions)
+      args.append(contentsOf: config.bazelFlags)
+      args.append(contentsOf: tulsiFlags.build)
+      args.append(contentsOf: targets)
+
+      return args.map { $0.escapingForShell }.joined(separator: " ")
+    }
+
+    let debugCommand = buildCommand(extraBuildFlags: extraDebugFlags, tulsiFlags: dbg)
+    let releaseCommand = buildCommand(extraBuildFlags: extraReleaseFlags, tulsiFlags: rel)
+
+    return (debugCommand, releaseCommand)
+  }
+
+  final func userBuildCommandForProject(_ projectURL: URL,
+                                        release: Bool = false,
+                                        targets: [String],
+                                        file: StaticString = #file,
+                                        line: UInt = #line) throws -> String {
+    let expectedScriptURL = projectURL.appendingPathComponent(".tulsi/Scripts/user_build.py",
+                                                               isDirectory: false)
+    let fileManager = FileManager.default
+
+    guard fileManager.fileExists(atPath: expectedScriptURL.path) else {
+      throw Error.userBuildScriptInvocationFailure(
+          "user_build.py script not found: expected at path \(expectedScriptURL.path)")
+    }
+
+    var output = "<none>"
+    let semaphore = DispatchSemaphore(value: 0)
+    var args = [
+        "--norun",
+    ]
+    if release {
+      args.append("--release")
+    }
+    args.append(contentsOf: targets)
+
+    let process = ProcessRunner.createProcess(
+      expectedScriptURL.path,
+      arguments: args,
+      messageLogger: localizedMessageLogger
+    ) { completionInfo in
+        defer {
+          semaphore.signal()
+        }
+        let exitcode = completionInfo.terminationStatus
+        guard exitcode == 0 else {
+          let stderr =
+            String(data: completionInfo.stderr, encoding: .utf8) ?? "<no stderr>"
+          XCTFail("user_build.py returned \(exitcode). stderr: \(stderr)", file: file, line: line)
+          return
+        }
+        if let stdout = String(data: completionInfo.stdout, encoding: .utf8)?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines),
+            !stdout.isEmpty {
+          output = stdout
+        } else {
+          let stderr =
+              String(data: completionInfo.stderr, encoding: .utf8) ?? "<no stderr>"
+          XCTFail("user_build.py had no stdout. stderr: \(stderr)", file: file, line: line)
+        }
+    }
+    process.currentDirectoryPath = workspaceRootURL.path
+    process.launch()
+
+    _ = semaphore.wait(timeout: DispatchTime.distantFuture)
+    return output
+  }
+
   final func generateProjectNamed(_ projectName: String,
                                   buildTargets: [RuleInfo],
                                   pathFilters: [String],
@@ -103,12 +213,13 @@
                                   outputDir: String,
                                   options: TulsiOptionSet = TulsiOptionSet()) throws -> URL {
     if !bazelStartupOptions.isEmpty {
-      options[.BazelBuildStartupOptionsDebug].projectValue =
-          bazelStartupOptions.joined(separator: " ")
+      let startupFlags = bazelStartupOptions.joined(separator: " ")
+      options[.BazelBuildStartupOptionsDebug].projectValue = startupFlags
+      options[.BazelBuildStartupOptionsRelease].projectValue = startupFlags
     }
 
-    let debugBuildOptions = ["--define=TULSI_TEST=dbg"] + bazelBuildOptions
-    let releaseBuildOptions = ["--define=TULSI_TEST=rel"] + bazelBuildOptions
+    let debugBuildOptions = extraDebugFlags + bazelBuildOptions
+    let releaseBuildOptions = extraReleaseFlags + bazelBuildOptions
 
     options[.BazelBuildOptionsDebug].projectValue = debugBuildOptions.joined(separator: " ")
     options[.BazelBuildOptionsRelease].projectValue = releaseBuildOptions.joined(separator: " ")
diff --git a/src/TulsiGeneratorTests/MockWorkspaceInfoExtractor.swift b/src/TulsiGeneratorTests/MockWorkspaceInfoExtractor.swift
index 2313041..5f61302 100644
--- a/src/TulsiGeneratorTests/MockWorkspaceInfoExtractor.swift
+++ b/src/TulsiGeneratorTests/MockWorkspaceInfoExtractor.swift
@@ -16,7 +16,10 @@
 @testable import TulsiGenerator
 
 class MockBazelSettingsProvider: BazelSettingsProviderProtocol {
-  func tulsiFlags(hasSwift: Bool, features: Set<BazelSettingFeature>) -> BazelFlagsSet {
+
+  func tulsiFlags(hasSwift: Bool,
+                  options: TulsiOptionSet?,
+                  features: Set<BazelSettingFeature>) -> BazelFlagsSet {
     return BazelFlagsSet()
   }