Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: Integration Tests

on:
push:
branches: [main]
pull_request:
branches: [main]

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
integration-test:
name: Integration Tests
runs-on: macos-latest
permissions:
contents: read

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Build CLI
run: swift build --product ackgen

- name: Run Integration Tests
run: swift test --filter IntegrationTests

- name: Test Example Project Setup
run: |
cd Example

# Simulate a real Xcode project environment
export SRCROOT="${PWD}"
export PROJECT_TEMP_DIR="${PWD}/Build/Intermediates.noindex/AckGenExample.build"

# Create mock directory structure
mkdir -p "${PROJECT_TEMP_DIR}"
mkdir -p "Build/DerivedData/AckGenExample-test/SourcePackages/checkouts/AckGen"

# The path calculation should work (using the improved logic)
BASE_DIR="${PROJECT_TEMP_DIR%/Build/*}"
CALCULATED_PATH="${BASE_DIR}/SourcePackages/checkouts"
echo "Calculated package path: ${CALCULATED_PATH}"

# Verify the calculation matches expected structure
if [[ "${CALCULATED_PATH}" == *"/SourcePackages/checkouts" ]]; then
echo "✅ Path calculation works correctly"
else
echo "❌ Path calculation failed"
exit 1
fi
14 changes: 9 additions & 5 deletions Example/ackgen.sh
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
# DIR=$PROJECT_TEMP_DIR/../../../SourcePackages/checkouts/AckGen
# Different path, because the sample uses AckGen as a local package:
# For the Example app, AckGen is used as a local package, so we use a simple relative path
# In your own project with SPM, use the dynamic path calculation from the README:
# BASE_DIR="${PROJECT_TEMP_DIR%/Build/*}"
# DIR="$BASE_DIR/SourcePackages/checkouts/AckGen"
DIR=..

if [ -d "$DIR" ]; then
cd $DIR
SDKROOT=(xcrun --sdk macosx --show-sdk-path)
swift run ackgen $SRCROOT/PackageLicenses.plist
cd "$DIR"
SDKROOT=$(xcrun --sdk macosx --show-sdk-path)
swift run ackgen "$SRCROOT/PackageLicenses.plist"
else
echo "warning: AckGen not found. Please install the package via SPM (https://github.com/MartinP7r/AckGen#installation)"
fi

46 changes: 43 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,23 @@ This can be used to feed a SwiftUI List or UITableView dataSource in your app.
2. Add the following as a Run Script for your target in Xcode

```sh
DIR=$PROJECT_TEMP_DIR/../../../SourcePackages/checkouts/AckGen
# Calculate the package path dynamically (works with various Xcode configurations)
# Use parameter expansion to remove everything from /Build/ onwards
BASE_DIR="${PROJECT_TEMP_DIR%/Build/*}"
DIR="$BASE_DIR/SourcePackages/checkouts/AckGen"

if [ -d "$DIR" ]; then
cd $DIR
SDKROOT=(xcrun --sdk macosx --show-sdk-path)
cd "$DIR"
SDKROOT=$(xcrun --sdk macosx --show-sdk-path)
swift run ackgen
else
echo "warning: AckGen not found. Please install the package via SPM (https://github.com/MartinP7r/AckGen#installation)"
fi
```

> **Note**
> The script dynamically calculates the package path from `PROJECT_TEMP_DIR` to support various Xcode project configurations. If you encounter issues, run `./diagnose_path.sh` from this repository to debug your setup.

Make sure to set `ENABLE_USER_SCRIPT_SANDBOXING` to `NO` in your build settings so the build phase above can write to the desired destination.

If you want the plist file to be saved somewhere other than `Acknowledgements.plist` at the root of your project (`$SRCROOT/Acknowledgements.plist`), you can provide a custom path as the first command line argument to `ackgen` above.
Expand Down Expand Up @@ -76,6 +83,39 @@ struct ContentView: View {

Until 1.0 is reached, minor versions will be breaking.

## Troubleshooting

### "AckGen not found" Error

If you see the warning `AckGen not found. Please install the package via SPM`, try the following:

1. **Run the diagnostic script**:
- Swift version: `swift diagnose_path.swift` (set `PROJECT_TEMP_DIR` environment variable first)
- Shell version: `./diagnose_path.sh` (set `PROJECT_TEMP_DIR` environment variable first)
2. **Verify SPM installation**: Make sure AckGen is added as a Swift Package dependency in your Xcode project
3. **Build your project**: SPM dependencies are only downloaded after you build your project at least once
4. **Check your path**: The script in the README dynamically calculates the package path. If your project uses a non-standard structure, you may need to adjust the path calculation

#### Understanding Path Detection

Xcode places SPM packages in different locations depending on your project setup. The recommended script uses:

```sh
BASE_DIR="${PROJECT_TEMP_DIR%/Build/*}"
DIR="$BASE_DIR/SourcePackages/checkouts/AckGen"
```

This uses bash parameter expansion to remove everything from `/Build/` onwards (including `/Build/` itself) and appends the standard SPM checkout path. This approach:
- Works across different Xcode versions
- Handles Debug/Release configurations
- Supports iOS/macOS/watchOS/tvOS targets
- Works with Simulator/Device builds
- Handles edge cases like usernames or project names containing "Build"

The calculation finds the **last** occurrence of `/Build/` in the path, ensuring correct behavior even when "Build" appears elsewhere in the path (e.g., `/Users/Build/Projects/MyApp/Build/Intermediates`).

If you need a different path for your setup (e.g., local package development), you can modify the `DIR` variable accordingly. See `Example/ackgen.sh` for an example of using a local package.

## Contribution

This is my first stab at building a Swift package and was mainly intended to be an exercise.
Expand Down
12 changes: 11 additions & 1 deletion Sources/AckGenCLI/AckGen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,17 @@ struct AckGenCLI {

let settingsTitle: String = arguments.count > 2 ? arguments[2] : "Acknowledgements"

let packageCachePath = tempDirPath.components(separatedBy: "/Build/")[0] + "/SourcePackages/checkouts"
// Calculate package cache path using improved logic
// Find the last occurrence of "/Build/" to handle edge cases like "Build" in username
let packageCachePath: String
if let range = tempDirPath.range(of: "/Build/", options: .backwards) {
let basePath = String(tempDirPath[..<range.lowerBound])
packageCachePath = basePath + "/SourcePackages/checkouts"
} else {
// Fallback to old logic if pattern not found
packageCachePath = tempDirPath.components(separatedBy: "/Build/")[0] + "/SourcePackages/checkouts"
}

let fman = FileManager.default

do {
Expand Down
180 changes: 180 additions & 0 deletions Tests/AckGenTests/IntegrationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import XCTest
import Foundation

/// Integration tests for AckGen CLI path detection and package discovery
final class IntegrationTests: XCTestCase {

var tempDir: URL!

override func setUp() {
super.setUp()
tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("AckGenIntegrationTests-\(UUID().uuidString)")
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
}

override func tearDown() {
if let tempDir = tempDir {
try? FileManager.default.removeItem(at: tempDir)
}
super.tearDown()
}

/// Helper function to calculate base path from PROJECT_TEMP_DIR using the production logic
/// This matches the logic in Sources/AckGenCLI/AckGen.swift
private func calculateBasePath(from projectTempDir: String) -> String {
if let range = projectTempDir.range(of: "/Build/", options: .backwards) {
return String(projectTempDir[..<range.lowerBound])
} else {
// If no /Build/ found, return the path as-is (edge case)
return projectTempDir
}
}

/// Test that the path calculation logic correctly extracts the base path from PROJECT_TEMP_DIR
func testPathCalculationFromProjectTempDir() {
// Given: Various PROJECT_TEMP_DIR patterns used by Xcode
let testCases: [(input: String, expectedBase: String)] = [
// Standard SPM project structure
(
"/Users/username/Library/Developer/Xcode/DerivedData/AppName-xyz/Build/Intermediates.noindex/AppName.build",
"/Users/username/Library/Developer/Xcode/DerivedData/AppName-xyz"
),
// Nested build directory
(
"/Users/username/Library/Developer/Xcode/DerivedData/AppName-abc/Build/Products/Debug/AppName.build",
"/Users/username/Library/Developer/Xcode/DerivedData/AppName-abc"
),
// Different user paths
(
"/Users/developer/Library/Developer/Xcode/DerivedData/MyProject-def/Build/Intermediates.noindex/MyProject.build",
"/Users/developer/Library/Developer/Xcode/DerivedData/MyProject-def"
),
]

// When & Then: Path calculation should correctly extract base directory
for testCase in testCases {
let calculatedBase = calculateBasePath(from: testCase.input)
let expectedPackagePath = calculatedBase + "/SourcePackages/checkouts"
let expectedBasePath = testCase.expectedBase + "/SourcePackages/checkouts"

XCTAssertEqual(expectedPackagePath, expectedBasePath,
"Path calculation failed for \(testCase.input)")
}
}

/// Test that the relative path approach from README matches the calculated path
func testRelativePathVsCalculatedPath() {
// Given: A simulated PROJECT_TEMP_DIR from Xcode
let projectTempDir = "/Users/username/Library/Developer/Xcode/DerivedData/AppName-xyz/Build/Intermediates.noindex/AppName.build"

// When: Calculate path using the improved CLI approach
let basePath = calculateBasePath(from: projectTempDir)
let calculatedPath = basePath + "/SourcePackages/checkouts"

// Expected path from base directory
let expectedPath = "/Users/username/Library/Developer/Xcode/DerivedData/AppName-xyz/SourcePackages/checkouts"

// Then: Paths should match
XCTAssertEqual(calculatedPath, expectedPath)

// The relative path "../../../" from PROJECT_TEMP_DIR would give:
// ../../../ from /Users/username/Library/Developer/Xcode/DerivedData/AppName-xyz/Build/Intermediates.noindex/AppName.build
// should reach: /Users/username/Library/Developer/Xcode/DerivedData/AppName-xyz/Build
// But we need: /Users/username/Library/Developer/Xcode/DerivedData/AppName-xyz

// This demonstrates why the relative path approach may fail
let url = URL(fileURLWithPath: projectTempDir)
let relativeUrl = url
.deletingLastPathComponent() // Remove AppName.build
.deletingLastPathComponent() // Remove Intermediates.noindex
.deletingLastPathComponent() // Remove Build

let relativeBasePath = relativeUrl.path + "/SourcePackages/checkouts"
XCTAssertEqual(relativeBasePath, expectedPath,
"Relative path should match calculated path")
}

/// Integration test: Create a mock package structure and verify discovery
func testPackageDiscoveryWithMockStructure() throws {
// Given: Create a mock SourcePackages/checkouts structure
let derivedDataDir = tempDir.appendingPathComponent("DerivedData/TestApp-abc")
let buildDir = derivedDataDir.appendingPathComponent("Build/Intermediates.noindex/TestApp.build")
let packagesDir = derivedDataDir.appendingPathComponent("SourcePackages/checkouts")

try FileManager.default.createDirectory(at: buildDir, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: packagesDir, withIntermediateDirectories: true)

// Create mock package directories with LICENSE files
let package1 = packagesDir.appendingPathComponent("TestPackage1")
let package2 = packagesDir.appendingPathComponent("TestPackage2")

try FileManager.default.createDirectory(at: package1, withIntermediateDirectories: true)
try FileManager.default.createDirectory(at: package2, withIntermediateDirectories: true)

let license1 = "MIT License\nCopyright (c) 2024 Test Package 1"
let license2 = "Apache License 2.0\nCopyright (c) 2024 Test Package 2"

try license1.write(to: package1.appendingPathComponent("LICENSE"), atomically: true, encoding: .utf8)
try license2.write(to: package2.appendingPathComponent("LICENSE.txt"), atomically: true, encoding: .utf8)

// When: Calculate package cache path from PROJECT_TEMP_DIR using improved logic
let projectTempDir = buildDir.path
let basePath = calculateBasePath(from: projectTempDir)
let calculatedPackagePath = basePath + "/SourcePackages/checkouts"

// Then: Should find the correct directory
XCTAssertEqual(calculatedPackagePath, packagesDir.path)
XCTAssertTrue(FileManager.default.fileExists(atPath: calculatedPackagePath))

// Should discover package directories
let discoveredPackages = try FileManager.default.contentsOfDirectory(atPath: calculatedPackagePath)
XCTAssertEqual(discoveredPackages.sorted(), ["TestPackage1", "TestPackage2"])
}

/// Test edge case: PROJECT_TEMP_DIR with multiple "Build" components
func testPathCalculationWithMultipleBuildComponents() {
// Given: A PROJECT_TEMP_DIR that contains "Build" multiple times
let projectTempDir = "/Users/Build/Projects/AppName-xyz/Build/Intermediates.noindex/AppName.build"

// When: Calculate path using improved logic (should find last "/Build/")
let basePath = calculateBasePath(from: projectTempDir)
let calculatedPath = basePath + "/SourcePackages/checkouts"

// Then: Should correctly identify the base path before the last Build/
let expectedPath = "/Users/Build/Projects/AppName-xyz/SourcePackages/checkouts"
XCTAssertEqual(calculatedPath, expectedPath)
}

/// Test that the path calculation handles various Xcode project configurations
func testPathCalculationForDifferentXcodeConfigurations() {
let configurations: [(name: String, tempDir: String, expectedBase: String)] = [
(
"Standard Debug Build",
"/Users/dev/Library/Developer/Xcode/DerivedData/App-xyz/Build/Intermediates.noindex/App.build/Debug-iphonesimulator/App.build",
"/Users/dev/Library/Developer/Xcode/DerivedData/App-xyz"
),
(
"Release Build",
"/Users/dev/Library/Developer/Xcode/DerivedData/App-xyz/Build/Intermediates.noindex/App.build/Release-iphoneos/App.build",
"/Users/dev/Library/Developer/Xcode/DerivedData/App-xyz"
),
(
"macOS Build",
"/Users/dev/Library/Developer/Xcode/DerivedData/MacApp-abc/Build/Intermediates.noindex/MacApp.build/Debug/MacApp.build",
"/Users/dev/Library/Developer/Xcode/DerivedData/MacApp-abc"
),
]

for config in configurations {
// When: Calculate package path using improved logic
let basePath = calculateBasePath(from: config.tempDir)
let calculatedPath = basePath + "/SourcePackages/checkouts"
let expectedPath = config.expectedBase + "/SourcePackages/checkouts"

// Then: Should correctly calculate path
XCTAssertEqual(calculatedPath, expectedPath,
"Failed for configuration: \(config.name)")
}
}
}
Loading