diff --git a/.claude/skills/tuist/SKILL.md b/.claude/skills/tuist/SKILL.md index fd482756..8c6a7c02 100644 --- a/.claude/skills/tuist/SKILL.md +++ b/.claude/skills/tuist/SKILL.md @@ -48,32 +48,39 @@ ReadMcpResourceTool( ## Project Options -The root `Project.swift` defines only the app target and UI tests target. All modules are SPM local packages referenced via `packages:`. Auto-generated schemes handle per-package testing. +The root `Project.swift` delegates to `mainApp.project` (defined in `ModuleKit/App.swift`). The project structure adapts based on the active module strategy set in `ModuleFactory.swift`. ```swift -// Project.swift (root — app + UI tests + module packages) -let project = Project( - name: appName, +// Project.swift +let project = mainApp.project + +// App.swift — generates the project with all module targets and schemes +Project( + name: name, options: .options( - automaticSchemesOptions: .enabled(codeCoverageEnabled: true), + automaticSchemesOptions: .disabled, // Manual scheme generation developmentRegion: "en", - disableBundleAccessors: true, // No TuistBundle+*.swift - disableSynthesizedResourceAccessors: true // No TuistAssets+*.swift + disableBundleAccessors: false, + disableSynthesizedResourceAccessors: true // No TuistAssets+*.swift + ), + packages: packages, // Aggregated from modules (empty for FrameworkModule) + settings: .settings( + base: baseSettings.merging([...]) { _, new in new }, + configurations: BuildConfiguration.all ), - packages: Modules.packageReferences, - targets: [appTarget, uiTestsTarget], - schemes: AppScheme.allSchemes() + [AppScheme.uiTestsScheme(), AppScheme.moduleTestsScheme()] + targets: [appTarget, uiTestsTarget] + moduleTargets, // App + UI tests + module framework targets + schemes: allSchemes + [uiTestsScheme] + moduleSchemes // Environment schemes + tests + per-module ) ``` ```swift -// Workspace.swift — autogenerated workspace schemes with relevant coverage +// Workspace.swift — coverage mode adapts to active strategy let workspace = Workspace( - name: appName, + name: mainApp.name, projects: ["."], generationOptions: .options( autogeneratedWorkspaceSchemes: .enabled( - codeCoverageMode: .relevant, + codeCoverageMode: mainApp.coverageMode, // .targets() for Framework, .relevant for SPM testingOptions: [] ) ) @@ -82,7 +89,7 @@ let workspace = Workspace( ### Module Test Scheme -The `Challenge (Dev)` scheme includes a `.xctestplan` file that aggregates all module test targets. Tests run via `xcodebuild test` (not `tuist test`) because Tuist can't resolve SPM package test targets when using `.testPlans()`. +The `Challenge (Dev)` scheme includes a `.xctestplan` file that aggregates all module test targets. `ModuleAggregation` always uses `.testPlans(...)` with an auto-generated test plan, regardless of the active strategy. This means the test command is always the same: ```bash xcodebuild test \ @@ -92,17 +99,14 @@ xcodebuild test \ -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=latest' ``` +Tests run via `xcodebuild test` (not `tuist test`) because Tuist can't resolve SPM package test targets when using `.testPlans()`. + ### Why disable synthesizers? | Option | Effect | Reason | |--------|--------|--------| -| `disableBundleAccessors` | No `TuistBundle+*.swift` | SPM generates `Bundle.module` for targets with resources | | `disableSynthesizedResourceAccessors` | No `TuistAssets+*.swift` | Not using generated asset accessors | -### Bundle.module - -SPM automatically generates `Bundle.module` for targets that declare resources in their `Package.swift`. No manual `Bundle+Module.swift` is needed for SPM local packages. - --- ## Localization Configuration @@ -182,7 +186,7 @@ Derived/ └── {AppName}UITests-Info.plist ``` -**Contents:** Only `Info.plist` files for the app and UI tests targets. Module Info.plists are managed by SPM. +**Contents:** Only `Info.plist` files for the app and UI tests targets. Module Info.plists are managed by their respective strategy (Tuist for Framework, SPM for SPM packages). **Git:** The `Derived/` folder is in `.gitignore`. diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 202adc9e..7e31d5fa 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -9,6 +9,10 @@ inputs: derived-data-prefix: description: Cache key prefix for DerivedData (e.g., unit, ui) required: true + module-strategy: + description: Module integration strategy (spm or framework) + required: false + default: framework runs: using: composite @@ -38,9 +42,9 @@ runs: uses: actions/cache@v4 with: path: derived_data - key: derived-data-${{ inputs.derived-data-prefix }}-${{ runner.os }}-${{ hashFiles('**/Project.swift', 'Tuist/Package.resolved') }}-${{ github.sha }} + key: derived-data-${{ inputs.derived-data-prefix }}-${{ runner.os }}-${{ hashFiles('**/Project.swift', 'Tuist/Package.resolved') }}-${{ inputs.module-strategy }}-${{ github.sha }} restore-keys: | - derived-data-${{ inputs.derived-data-prefix }}-${{ runner.os }}-${{ hashFiles('**/Project.swift', 'Tuist/Package.resolved') }}- + derived-data-${{ inputs.derived-data-prefix }}-${{ runner.os }}-${{ hashFiles('**/Project.swift', 'Tuist/Package.resolved') }}-${{ inputs.module-strategy }}- derived-data-${{ inputs.derived-data-prefix }}-${{ runner.os }}- - name: Install SPM dependencies @@ -49,7 +53,7 @@ runs: - name: Generate Xcode project shell: bash - run: mise x -- tuist generate + run: TUIST_MODULE_STRATEGY=${{ inputs.module-strategy }} mise x -- tuist generate # Simulator Readiness Strategy # diff --git a/.github/actions/test-report/action.yml b/.github/actions/test-report/action.yml index 5064e7a5..c5863150 100644 --- a/.github/actions/test-report/action.yml +++ b/.github/actions/test-report/action.yml @@ -140,12 +140,15 @@ runs: - name: Comment PR with test summary if: github.event_name == 'pull_request' && steps.test-summary.outputs.has_info == 'true' + continue-on-error: true uses: actions/github-script@v7 + env: + COMMENT_BODY: ${{ steps.test-summary.outputs.body }} with: script: | await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, - body: `${{ steps.test-summary.outputs.body }}` + body: process.env.COMMENT_BODY }); diff --git a/.github/workflows/clear-caches.yml b/.github/workflows/clear-caches.yml new file mode 100644 index 00000000..dfbb909c --- /dev/null +++ b/.github/workflows/clear-caches.yml @@ -0,0 +1,63 @@ +name: Clear Caches + +on: + workflow_dispatch: + inputs: + scope: + description: "Which caches to clear" + required: true + type: choice + options: + - all + - derived-data + - tuist + - mise + +jobs: + clear: + name: Clear caches + runs-on: ubuntu-latest + steps: + - name: Clear caches + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + SCOPE: ${{ inputs.scope }} + run: | + echo "Fetching cache list..." + CACHES=$(gh cache list --json id,key --limit 100) + + filter_and_delete() { + local prefix="$1" + local ids + ids=$(echo "$CACHES" | jq -r --arg p "$prefix" '.[] | select(.key | startswith($p)) | .id') + local count + count=$(echo "$ids" | grep -c . || true) + if [ "$count" -eq 0 ]; then + echo "No caches found with prefix '$prefix'" + return + fi + echo "Deleting $count caches with prefix '$prefix'..." + echo "$ids" | while read -r id; do + gh cache delete "$id" && echo " Deleted $id" || echo " Failed to delete $id" + done + } + + case "$SCOPE" in + all) + filter_and_delete "derived-data-" + filter_and_delete "tuist-" + filter_and_delete "mise-" + ;; + derived-data) + filter_and_delete "derived-data-" + ;; + tuist) + filter_and_delete "tuist-" + ;; + mise) + filter_and_delete "mise-" + ;; + esac + + echo "Done." diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 8bc78764..ebbe2691 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -4,6 +4,18 @@ on: pull_request: branches: [main] workflow_dispatch: + inputs: + module-strategy: + description: "Module integration strategy (spm or framework)" + required: false + default: framework + type: choice + options: + - framework + - spm + +env: + MODULE_STRATEGY: ${{ github.event.inputs.module-strategy || 'framework' }} concurrency: group: ci-${{ github.ref }} @@ -34,9 +46,10 @@ jobs: with: device: ${{ env.TUIST_TEST_DEVICE }} derived-data-prefix: unit + module-strategy: ${{ env.MODULE_STRATEGY }} - name: Generate project - run: mise x -- tuist generate --no-open + run: TUIST_MODULE_STRATEGY=${{ env.MODULE_STRATEGY }} mise x -- tuist generate --no-open - name: Run unit and snapshot tests timeout-minutes: 35 @@ -166,6 +179,7 @@ jobs: with: device: ${{ env.TUIST_TEST_DEVICE }} derived-data-prefix: ui + module-strategy: ${{ env.MODULE_STRATEGY }} - name: Run UI tests timeout-minutes: 35 @@ -219,45 +233,120 @@ jobs: pattern: '*-coverage' - name: Merge coverage data + id: merge run: | mkdir -p merged-output - xcrun xcresulttool merge \ - unit-snapshot-coverage/UnitSnapshot.xcresult \ - ui-test-coverage/UITests.xcresult \ - --output-path merged-output/merged.xcresult - xcrun xccov view --report --json merged-output/merged.xcresult > coverage.json + if xcrun xcresulttool merge \ + unit-snapshot-coverage/UnitSnapshot.xcresult \ + ui-test-coverage/UITests.xcresult \ + --output-path merged-output/merged.xcresult 2>&1; then + echo "merge_ok=true" >> "$GITHUB_OUTPUT" + xcrun xccov view --report --json merged-output/merged.xcresult > coverage.json + else + echo "::warning::xcresulttool merge failed — falling back to independent coverage extraction" + echo "merge_ok=false" >> "$GITHUB_OUTPUT" + xcrun xccov view --report --json unit-snapshot-coverage/UnitSnapshot.xcresult > unit-coverage.json + xcrun xccov view --report --json ui-test-coverage/UITests.xcresult > ui-coverage.json + fi - name: Upload merged xcresult - id: upload-xcresult + if: steps.merge.outputs.merge_ok == 'true' + id: upload-merged uses: actions/upload-artifact@v4 with: name: merged-xcresult path: merged-output retention-days: 7 + - name: Upload unit xcresult + if: steps.merge.outputs.merge_ok != 'true' + id: upload-unit + uses: actions/upload-artifact@v4 + with: + name: unit-snapshot-xcresult + path: unit-snapshot-coverage + retention-days: 7 + + - name: Upload UI xcresult + if: steps.merge.outputs.merge_ok != 'true' + id: upload-ui + uses: actions/upload-artifact@v4 + with: + name: ui-test-xcresult + path: ui-test-coverage + retention-days: 7 + - name: Coverage report id: coverage uses: actions/github-script@v7 env: THRESHOLD: ${{ env.COVERAGE_THRESHOLD }} - XCRESULT_ARTIFACT_ID: ${{ steps.upload-xcresult.outputs.artifact-id }} + MERGE_OK: ${{ steps.merge.outputs.merge_ok }} + MERGED_ARTIFACT_ID: ${{ steps.upload-merged.outputs.artifact-id }} + UNIT_ARTIFACT_ID: ${{ steps.upload-unit.outputs.artifact-id }} + UI_ARTIFACT_ID: ${{ steps.upload-ui.outputs.artifact-id }} with: script: | const fs = require('fs'); - const report = JSON.parse(fs.readFileSync('coverage.json', 'utf8')); const THRESHOLD = parseInt(process.env.THRESHOLD, 10); + const mergeOk = process.env.MERGE_OK === 'true'; + + // --- Load coverage targets --- + let targets; + if (mergeOk) { + const report = JSON.parse(fs.readFileSync('coverage.json', 'utf8')); + targets = report.targets; + } else { + // Merge coverage at file level from both reports + const unitReport = JSON.parse(fs.readFileSync('unit-coverage.json', 'utf8')); + const uiReport = JSON.parse(fs.readFileSync('ui-coverage.json', 'utf8')); + + const targetMap = new Map(); + for (const report of [unitReport, uiReport]) { + for (const target of report.targets) { + if (!targetMap.has(target.name)) { + targetMap.set(target.name, new Map()); + } + const fileMap = targetMap.get(target.name); + for (const file of (target.files || [])) { + const key = file.path || file.name; + const existing = fileMap.get(key); + if (!existing || file.coveredLines > existing.coveredLines) { + fileMap.set(key, { + coveredLines: file.coveredLines, + executableLines: file.executableLines, + }); + } + } + } + } + + targets = []; + for (const [name, fileMap] of targetMap) { + let coveredLines = 0; + let executableLines = 0; + for (const f of fileMap.values()) { + coveredLines += f.coveredLines; + executableLines += f.executableLines; + } + const lineCoverage = executableLines > 0 ? coveredLines / executableLines : 0; + targets.push({ name, coveredLines, executableLines, lineCoverage }); + } + } - const targets = report.targets.filter(t => - !t.name.includes('Mock') && - !t.name.includes('Test') && - !t.name.includes('SnapshotTestKit') && - t.name !== 'SnapshotTesting' - ); + // --- Filter targets --- + const filtered = targets.filter(t => { + const name = t.name.replace(/\.(framework|app|o)$/, ''); + return !name.includes('Mock') && + !name.includes('Test') && + !name.includes('SnapshotTestKit') && + name !== 'SnapshotTesting'; + }); let totalCovered = 0; let totalExecutable = 0; - const rows = targets + const rows = filtered .sort((a, b) => a.name.localeCompare(b.name)) .map(t => { totalCovered += t.coveredLines; @@ -273,8 +362,20 @@ jobs: : 0; const totalIcon = totalPct >= THRESHOLD ? ':white_check_mark:' : ':x:'; - const artifactId = process.env.XCRESULT_ARTIFACT_ID; - const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/artifacts/${artifactId}`; + // --- Download links --- + const baseUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}/artifacts`; + let downloadSection; + if (mergeOk) { + const id = process.env.MERGED_ARTIFACT_ID; + downloadSection = `:package: [Download xcresult](${baseUrl}/${id})`; + } else { + const unitId = process.env.UNIT_ARTIFACT_ID; + const uiId = process.env.UI_ARTIFACT_ID; + downloadSection = [ + `:package: [Unit+Snapshot xcresult](${baseUrl}/${unitId})`, + `:package: [UI Tests xcresult](${baseUrl}/${uiId})`, + ].join('\n'); + } const body = [ '## Code Coverage', @@ -286,7 +387,7 @@ jobs: '', `Minimum required: ${THRESHOLD}%`, '', - `:package: [Download xcresult](${runUrl})` + downloadSection, ].join('\n'); fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, body + '\n'); diff --git a/.github/workflows/run-single-test.yml b/.github/workflows/run-single-test.yml index a4d4382c..13d8b881 100644 --- a/.github/workflows/run-single-test.yml +++ b/.github/workflows/run-single-test.yml @@ -11,6 +11,9 @@ on: required: false default: "1" +env: + MODULE_STRATEGY: framework + concurrency: group: single-test-${{ github.ref }} cancel-in-progress: true @@ -38,6 +41,7 @@ jobs: with: device: ${{ env.TUIST_TEST_DEVICE }} derived-data-prefix: single-test + module-strategy: ${{ env.MODULE_STRATEGY }} - name: Run test (${{ github.event.inputs.repeat-count }}x) run: | diff --git a/.gitignore b/.gitignore index 07bdf1e3..8c8bde5e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,10 +20,15 @@ build/ # Tuist Derived/ +*.xctestplan Tuist/Dependencies/ Tuist/.build/ Tuist/.cache/ +# Auto-generated SPM package manifests (SPM strategy) +**/Package.swift +!Tuist/Package.swift + # Swift Package Manager .swiftpm/ Packages/ diff --git a/App/Tests/Shared/Extensions/Bundle+Module.swift b/App/Tests/Shared/Extensions/Bundle+Module.swift deleted file mode 100644 index d187e56e..00000000 --- a/App/Tests/Shared/Extensions/Bundle+Module.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -private final class BundleFinder {} - -extension Bundle { - /// The bundle associated with the current Swift module. - static let module = Bundle(for: BundleFinder.self) -} diff --git a/AppKit/Package.swift b/AppKit/Package.swift deleted file mode 100644 index b8c5b3f1..00000000 --- a/AppKit/Package.swift +++ /dev/null @@ -1,53 +0,0 @@ -// swift-tools-version: 6.2 -import PackageDescription - -let mainActorSettings: [SwiftSetting] = [ - .defaultIsolation(MainActor.self), - .enableExperimentalFeature("ApproachableConcurrency"), -] - -let package = Package( - name: "ChallengeAppKit", - platforms: [.iOS(.v17)], - products: [ - .library(name: "ChallengeAppKit", targets: ["ChallengeAppKit"]), - ], - dependencies: [ - .package(path: "../Libraries/Core"), - .package(path: "../Libraries/Networking"), - .package(path: "../Features/Home"), - .package(path: "../Features/Character"), - .package(path: "../Features/Episode"), - .package(path: "../Features/System"), - .package(path: "../Libraries/SnapshotTestKit"), - ], - targets: [ - .target( - name: "ChallengeAppKit", - dependencies: [ - .product(name: "ChallengeCore", package: "Core"), - .product(name: "ChallengeHome", package: "Home"), - .product(name: "ChallengeCharacter", package: "Character"), - .product(name: "ChallengeEpisode", package: "Episode"), - .product(name: "ChallengeSystem", package: "System"), - .product(name: "ChallengeNetworking", package: "Networking"), - ], - path: "Sources", - swiftSettings: mainActorSettings - ), - .testTarget( - name: "ChallengeAppKitTests", - dependencies: [ - "ChallengeAppKit", - .product(name: "ChallengeCoreMocks", package: "Core"), - .product(name: "ChallengeNetworkingMocks", package: "Networking"), - .product(name: "ChallengeSnapshotTestKit", package: "SnapshotTestKit"), - ], - path: "Tests", - exclude: [ - "Snapshots/Presentation/__Snapshots__", - ], - swiftSettings: mainActorSettings - ), - ] -) diff --git a/Challenge.xctestplan b/Challenge.xctestplan deleted file mode 100644 index 01644fcf..00000000 --- a/Challenge.xctestplan +++ /dev/null @@ -1,120 +0,0 @@ -{ - "configurations" : [ - { - "id" : "E9F7D3A1-4B2C-4F8E-A1D6-3C5E7F9A2B4D", - "name" : "Test Scheme Action", - "options" : { - - } - } - ], - "defaultOptions" : { - "codeCoverage" : true, - "codeCoverageTargets" : [ - { - "containerPath" : "container:", - "identifier" : "Challenge", - "name" : "Challenge" - }, - { - "containerPath" : "container:Libraries\/Core", - "identifier" : "ChallengeCore", - "name" : "ChallengeCore" - }, - { - "containerPath" : "container:Libraries\/Networking", - "identifier" : "ChallengeNetworking", - "name" : "ChallengeNetworking" - }, - { - "containerPath" : "container:Libraries\/DesignSystem", - "identifier" : "ChallengeDesignSystem", - "name" : "ChallengeDesignSystem" - }, - { - "containerPath" : "container:Features\/Character", - "identifier" : "ChallengeCharacter", - "name" : "ChallengeCharacter" - }, - { - "containerPath" : "container:Features\/Episode", - "identifier" : "ChallengeEpisode", - "name" : "ChallengeEpisode" - }, - { - "containerPath" : "container:Features\/Home", - "identifier" : "ChallengeHome", - "name" : "ChallengeHome" - }, - { - "containerPath" : "container:Features\/System", - "identifier" : "ChallengeSystem", - "name" : "ChallengeSystem" - }, - { - "containerPath" : "container:AppKit", - "identifier" : "ChallengeAppKit", - "name" : "ChallengeAppKit" - } - ] - }, - "testTargets" : [ - { - "target" : { - "containerPath" : "container:Libraries\/Core", - "identifier" : "ChallengeCoreTests", - "name" : "ChallengeCoreTests" - } - }, - { - "target" : { - "containerPath" : "container:Libraries\/Networking", - "identifier" : "ChallengeNetworkingTests", - "name" : "ChallengeNetworkingTests" - } - }, - { - "target" : { - "containerPath" : "container:Libraries\/DesignSystem", - "identifier" : "ChallengeDesignSystemTests", - "name" : "ChallengeDesignSystemTests" - } - }, - { - "target" : { - "containerPath" : "container:Features\/Character", - "identifier" : "ChallengeCharacterTests", - "name" : "ChallengeCharacterTests" - } - }, - { - "target" : { - "containerPath" : "container:Features\/Episode", - "identifier" : "ChallengeEpisodeTests", - "name" : "ChallengeEpisodeTests" - } - }, - { - "target" : { - "containerPath" : "container:Features\/Home", - "identifier" : "ChallengeHomeTests", - "name" : "ChallengeHomeTests" - } - }, - { - "target" : { - "containerPath" : "container:Features\/System", - "identifier" : "ChallengeSystemTests", - "name" : "ChallengeSystemTests" - } - }, - { - "target" : { - "containerPath" : "container:AppKit", - "identifier" : "ChallengeAppKitTests", - "name" : "ChallengeAppKitTests" - } - } - ], - "version" : 1 -} diff --git a/Features/Character/Package.swift b/Features/Character/Package.swift deleted file mode 100644 index d260b9b6..00000000 --- a/Features/Character/Package.swift +++ /dev/null @@ -1,55 +0,0 @@ -// swift-tools-version: 6.2 -import PackageDescription - -let mainActorSettings: [SwiftSetting] = [ - .defaultIsolation(MainActor.self), - .enableExperimentalFeature("ApproachableConcurrency"), -] - -let package = Package( - name: "ChallengeCharacter", - platforms: [.iOS(.v17)], - products: [ - .library(name: "ChallengeCharacter", targets: ["ChallengeCharacter"]), - ], - dependencies: [ - .package(path: "../../Libraries/Core"), - .package(path: "../../Libraries/Networking"), - .package(path: "../../Shared/Resources"), - .package(path: "../../Libraries/DesignSystem"), - .package(path: "../../Libraries/SnapshotTestKit"), - ], - targets: [ - .target( - name: "ChallengeCharacter", - dependencies: [ - .product(name: "ChallengeCore", package: "Core"), - .product(name: "ChallengeNetworking", package: "Networking"), - .product(name: "ChallengeResources", package: "Resources"), - .product(name: "ChallengeDesignSystem", package: "DesignSystem"), - ], - path: "Sources", - swiftSettings: mainActorSettings - ), - .testTarget( - name: "ChallengeCharacterTests", - dependencies: [ - "ChallengeCharacter", - .product(name: "ChallengeCoreMocks", package: "Core"), - .product(name: "ChallengeNetworkingMocks", package: "Networking"), - .product(name: "ChallengeSnapshotTestKit", package: "SnapshotTestKit"), - ], - path: "Tests", - exclude: [ - "Snapshots/Presentation/CharacterDetail/__Snapshots__", - "Snapshots/Presentation/CharacterFilter/__Snapshots__", - "Snapshots/Presentation/CharacterList/__Snapshots__", - ], - resources: [ - .process("Shared/Fixtures"), - .process("Shared/Resources"), - ], - swiftSettings: mainActorSettings - ), - ] -) diff --git a/Features/Character/Sources/Presentation/CharacterDetail/ViewModels/CharacterDetailViewState.swift b/Features/Character/Sources/Presentation/CharacterDetail/ViewModels/CharacterDetailViewState.swift index 6c77843b..5e0def1e 100644 --- a/Features/Character/Sources/Presentation/CharacterDetail/ViewModels/CharacterDetailViewState.swift +++ b/Features/Character/Sources/Presentation/CharacterDetail/ViewModels/CharacterDetailViewState.swift @@ -1,6 +1,6 @@ import Foundation -enum CharacterDetailViewState { +enum CharacterDetailViewState: Equatable { case idle case loading case loaded(Character) diff --git a/Features/Character/Sources/Presentation/CharacterList/ViewModels/CharacterListViewState.swift b/Features/Character/Sources/Presentation/CharacterList/ViewModels/CharacterListViewState.swift index c3af38f2..ef8ef96d 100644 --- a/Features/Character/Sources/Presentation/CharacterList/ViewModels/CharacterListViewState.swift +++ b/Features/Character/Sources/Presentation/CharacterList/ViewModels/CharacterListViewState.swift @@ -1,6 +1,6 @@ import Foundation -enum CharacterListViewState { +enum CharacterListViewState: Equatable { case idle case loading case loaded(CharactersPage) diff --git a/Features/Character/Tests/Shared/Extensions/CharacterDetailViewState+Equatable.swift b/Features/Character/Tests/Shared/Extensions/CharacterDetailViewState+Equatable.swift deleted file mode 100644 index ddac05fd..00000000 --- a/Features/Character/Tests/Shared/Extensions/CharacterDetailViewState+Equatable.swift +++ /dev/null @@ -1,16 +0,0 @@ -@testable import ChallengeCharacter - -extension CharacterDetailViewState: Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - switch (lhs, rhs) { - case (.idle, .idle), (.loading, .loading): - true - case let (.loaded(lhsCharacter), .loaded(rhsCharacter)): - lhsCharacter == rhsCharacter - case let (.error(lhsError), .error(rhsError)): - lhsError == rhsError - default: - false - } - } -} diff --git a/Features/Character/Tests/Shared/Extensions/CharacterListViewState+Equatable.swift b/Features/Character/Tests/Shared/Extensions/CharacterListViewState+Equatable.swift deleted file mode 100644 index 9a0fdc83..00000000 --- a/Features/Character/Tests/Shared/Extensions/CharacterListViewState+Equatable.swift +++ /dev/null @@ -1,16 +0,0 @@ -@testable import ChallengeCharacter - -extension CharacterListViewState: Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - switch (lhs, rhs) { - case (.idle, .idle), (.loading, .loading), (.empty, .empty), (.emptySearch, .emptySearch): - true - case let (.loaded(lhsPage), .loaded(rhsPage)): - lhsPage == rhsPage - case let (.error(lhsError), .error(rhsError)): - lhsError == rhsError - default: - false - } - } -} diff --git a/Features/Episode/Package.swift b/Features/Episode/Package.swift deleted file mode 100644 index db649d79..00000000 --- a/Features/Episode/Package.swift +++ /dev/null @@ -1,53 +0,0 @@ -// swift-tools-version: 6.2 -import PackageDescription - -let mainActorSettings: [SwiftSetting] = [ - .defaultIsolation(MainActor.self), - .enableExperimentalFeature("ApproachableConcurrency"), -] - -let package = Package( - name: "ChallengeEpisode", - platforms: [.iOS(.v17)], - products: [ - .library(name: "ChallengeEpisode", targets: ["ChallengeEpisode"]), - ], - dependencies: [ - .package(path: "../../Libraries/Core"), - .package(path: "../../Libraries/Networking"), - .package(path: "../../Shared/Resources"), - .package(path: "../../Libraries/DesignSystem"), - .package(path: "../../Libraries/SnapshotTestKit"), - ], - targets: [ - .target( - name: "ChallengeEpisode", - dependencies: [ - .product(name: "ChallengeCore", package: "Core"), - .product(name: "ChallengeDesignSystem", package: "DesignSystem"), - .product(name: "ChallengeNetworking", package: "Networking"), - .product(name: "ChallengeResources", package: "Resources"), - ], - path: "Sources", - swiftSettings: mainActorSettings - ), - .testTarget( - name: "ChallengeEpisodeTests", - dependencies: [ - "ChallengeEpisode", - .product(name: "ChallengeCoreMocks", package: "Core"), - .product(name: "ChallengeNetworkingMocks", package: "Networking"), - .product(name: "ChallengeSnapshotTestKit", package: "SnapshotTestKit"), - ], - path: "Tests", - exclude: [ - "Snapshots/Presentation/CharacterEpisodes/__Snapshots__", - ], - resources: [ - .process("Shared/Fixtures"), - .process("Shared/Resources"), - ], - swiftSettings: mainActorSettings - ), - ] -) diff --git a/Features/Episode/Sources/Presentation/CharacterEpisodes/ViewModels/CharacterEpisodesViewState.swift b/Features/Episode/Sources/Presentation/CharacterEpisodes/ViewModels/CharacterEpisodesViewState.swift index 8ebc3dd8..f1b6192e 100644 --- a/Features/Episode/Sources/Presentation/CharacterEpisodes/ViewModels/CharacterEpisodesViewState.swift +++ b/Features/Episode/Sources/Presentation/CharacterEpisodes/ViewModels/CharacterEpisodesViewState.swift @@ -1,6 +1,6 @@ import Foundation -enum CharacterEpisodesViewState { +enum CharacterEpisodesViewState: Equatable { case idle case loading case loaded(EpisodeCharacterWithEpisodes) diff --git a/Features/Episode/Tests/Shared/Extensions/CharacterEpisodesViewState+Equatable.swift b/Features/Episode/Tests/Shared/Extensions/CharacterEpisodesViewState+Equatable.swift deleted file mode 100644 index b9017f4d..00000000 --- a/Features/Episode/Tests/Shared/Extensions/CharacterEpisodesViewState+Equatable.swift +++ /dev/null @@ -1,16 +0,0 @@ -@testable import ChallengeEpisode - -extension CharacterEpisodesViewState: Equatable { - public static func == (lhs: Self, rhs: Self) -> Bool { - switch (lhs, rhs) { - case (.idle, .idle), (.loading, .loading): - true - case let (.loaded(lhsData), .loaded(rhsData)): - lhsData == rhsData - case let (.error(lhsError), .error(rhsError)): - lhsError.localizedDescription == rhsError.localizedDescription - default: - false - } - } -} diff --git a/Features/Home/Package.swift b/Features/Home/Package.swift deleted file mode 100644 index f7297661..00000000 --- a/Features/Home/Package.swift +++ /dev/null @@ -1,51 +0,0 @@ -// swift-tools-version: 6.2 -import PackageDescription - -let mainActorSettings: [SwiftSetting] = [ - .defaultIsolation(MainActor.self), - .enableExperimentalFeature("ApproachableConcurrency"), -] - -let package = Package( - name: "ChallengeHome", - platforms: [.iOS(.v17)], - products: [ - .library(name: "ChallengeHome", targets: ["ChallengeHome"]), - ], - dependencies: [ - .package(path: "../../Libraries/Core"), - .package(path: "../../Libraries/DesignSystem"), - .package(path: "../../Shared/Resources"), - .package(url: "https://github.com/airbnb/lottie-ios", from: "4.6.0"), - .package(path: "../../Libraries/SnapshotTestKit"), - ], - targets: [ - .target( - name: "ChallengeHome", - dependencies: [ - .product(name: "ChallengeCore", package: "Core"), - .product(name: "ChallengeDesignSystem", package: "DesignSystem"), - .product(name: "ChallengeResources", package: "Resources"), - .product(name: "Lottie", package: "lottie-ios"), - ], - path: "Sources", - resources: [ - .process("Resources"), - ], - swiftSettings: mainActorSettings - ), - .testTarget( - name: "ChallengeHomeTests", - dependencies: [ - "ChallengeHome", - .product(name: "ChallengeCoreMocks", package: "Core"), - .product(name: "ChallengeSnapshotTestKit", package: "SnapshotTestKit"), - ], - path: "Tests", - exclude: [ - "Snapshots/Presentation/__Snapshots__", - ], - swiftSettings: mainActorSettings - ), - ] -) diff --git a/Features/System/Package.swift b/Features/System/Package.swift deleted file mode 100644 index 980bfa69..00000000 --- a/Features/System/Package.swift +++ /dev/null @@ -1,46 +0,0 @@ -// swift-tools-version: 6.2 -import PackageDescription - -let mainActorSettings: [SwiftSetting] = [ - .defaultIsolation(MainActor.self), - .enableExperimentalFeature("ApproachableConcurrency"), -] - -let package = Package( - name: "ChallengeSystem", - platforms: [.iOS(.v17)], - products: [ - .library(name: "ChallengeSystem", targets: ["ChallengeSystem"]), - ], - dependencies: [ - .package(path: "../../Libraries/Core"), - .package(path: "../../Shared/Resources"), - .package(path: "../../Libraries/DesignSystem"), - .package(path: "../../Libraries/SnapshotTestKit"), - ], - targets: [ - .target( - name: "ChallengeSystem", - dependencies: [ - .product(name: "ChallengeCore", package: "Core"), - .product(name: "ChallengeResources", package: "Resources"), - .product(name: "ChallengeDesignSystem", package: "DesignSystem"), - ], - path: "Sources", - swiftSettings: mainActorSettings - ), - .testTarget( - name: "ChallengeSystemTests", - dependencies: [ - "ChallengeSystem", - .product(name: "ChallengeCoreMocks", package: "Core"), - .product(name: "ChallengeSnapshotTestKit", package: "SnapshotTestKit"), - ], - path: "Tests", - exclude: [ - "Snapshots/Presentation/__Snapshots__", - ], - swiftSettings: mainActorSettings - ), - ] -) diff --git a/Libraries/Core/Package.swift b/Libraries/Core/Package.swift deleted file mode 100644 index 0fca1116..00000000 --- a/Libraries/Core/Package.swift +++ /dev/null @@ -1,35 +0,0 @@ -// swift-tools-version: 6.2 -import PackageDescription - -let mainActorSettings: [SwiftSetting] = [ - .defaultIsolation(MainActor.self), - .enableExperimentalFeature("ApproachableConcurrency"), -] - -let package = Package( - name: "ChallengeCore", - platforms: [.iOS(.v17)], - products: [ - .library(name: "ChallengeCore", targets: ["ChallengeCore"]), - .library(name: "ChallengeCoreMocks", targets: ["ChallengeCoreMocks"]), - ], - targets: [ - .target( - name: "ChallengeCore", - path: "Sources", - swiftSettings: mainActorSettings - ), - .target( - name: "ChallengeCoreMocks", - dependencies: ["ChallengeCore"], - path: "Mocks", - swiftSettings: mainActorSettings - ), - .testTarget( - name: "ChallengeCoreTests", - dependencies: ["ChallengeCore", "ChallengeCoreMocks"], - path: "Tests", - swiftSettings: mainActorSettings - ), - ] -) diff --git a/Libraries/Core/Tests/Unit/ImageLoader/ImageDiskCacheTests.swift b/Libraries/Core/Tests/Unit/ImageLoader/ImageDiskCacheTests.swift index bf25d462..b2182523 100644 --- a/Libraries/Core/Tests/Unit/ImageLoader/ImageDiskCacheTests.swift +++ b/Libraries/Core/Tests/Unit/ImageLoader/ImageDiskCacheTests.swift @@ -192,8 +192,7 @@ struct ImageDiskCacheTests { // When await sut.remove(for: testURL) - // Then - #expect(fileSystemMock.files.count == 1) + // Then — verify through actor (avoids cross-thread mock access) let removedResult = await sut.image(for: testURL) let otherResult = await sut.image(for: otherURL) #expect(removedResult == nil) @@ -238,8 +237,11 @@ struct ImageDiskCacheTests { // When await sut.removeAll() - // Then - #expect(fileSystemMock.files.isEmpty) + // Then — verify through actor (avoids cross-thread mock access) + let result1 = await sut.image(for: url1) + let result2 = await sut.image(for: url2) + #expect(result1 == nil) + #expect(result2 == nil) } // MARK: - Empty Data diff --git a/Libraries/DesignSystem/Package.swift b/Libraries/DesignSystem/Package.swift deleted file mode 100644 index 7fdabf67..00000000 --- a/Libraries/DesignSystem/Package.swift +++ /dev/null @@ -1,49 +0,0 @@ -// swift-tools-version: 6.2 -import PackageDescription - -let mainActorSettings: [SwiftSetting] = [ - .defaultIsolation(MainActor.self), - .enableExperimentalFeature("ApproachableConcurrency"), -] - -let package = Package( - name: "ChallengeDesignSystem", - platforms: [.iOS(.v17)], - products: [ - .library(name: "ChallengeDesignSystem", targets: ["ChallengeDesignSystem"]), - ], - dependencies: [ - .package(path: "../Core"), - .package(path: "../SnapshotTestKit"), - ], - targets: [ - .target( - name: "ChallengeDesignSystem", - dependencies: [ - .product(name: "ChallengeCore", package: "Core"), - ], - path: "Sources", - swiftSettings: mainActorSettings - ), - .testTarget( - name: "ChallengeDesignSystemTests", - dependencies: [ - "ChallengeDesignSystem", - .product(name: "ChallengeCoreMocks", package: "Core"), - .product(name: "ChallengeSnapshotTestKit", package: "SnapshotTestKit"), - ], - path: "Tests", - exclude: [ - "Snapshots/Atoms/__Snapshots__", - "Snapshots/Extensions/__Snapshots__", - "Snapshots/Molecules/__Snapshots__", - "Snapshots/Organisms/__Snapshots__", - "Snapshots/Theme/__Snapshots__", - ], - resources: [ - .process("Shared/Resources"), - ], - swiftSettings: mainActorSettings - ), - ] -) diff --git a/Libraries/DesignSystem/Tests/Snapshots/Atoms/DSAsyncImageSnapshotTests.swift b/Libraries/DesignSystem/Tests/Snapshots/Atoms/DSAsyncImageSnapshotTests.swift index 0379b35b..b0dbbe96 100644 --- a/Libraries/DesignSystem/Tests/Snapshots/Atoms/DSAsyncImageSnapshotTests.swift +++ b/Libraries/DesignSystem/Tests/Snapshots/Atoms/DSAsyncImageSnapshotTests.swift @@ -153,6 +153,10 @@ private actor LoadSignal { return } await withCheckedContinuation { continuation in + if isCompleted { + continuation.resume() + return + } self.continuation = continuation } } diff --git a/Libraries/Networking/Package.swift b/Libraries/Networking/Package.swift deleted file mode 100644 index ac51d784..00000000 --- a/Libraries/Networking/Package.swift +++ /dev/null @@ -1,41 +0,0 @@ -// swift-tools-version: 6.2 -import PackageDescription - -let nonisolatedSettings: [SwiftSetting] = [ - .enableExperimentalFeature("ApproachableConcurrency"), -] - -let package = Package( - name: "ChallengeNetworking", - platforms: [.iOS(.v17)], - products: [ - .library(name: "ChallengeNetworking", targets: ["ChallengeNetworking"]), - .library(name: "ChallengeNetworkingMocks", targets: ["ChallengeNetworkingMocks"]), - ], - dependencies: [ - .package(path: "../Core"), - ], - targets: [ - .target( - name: "ChallengeNetworking", - path: "Sources", - swiftSettings: nonisolatedSettings - ), - .target( - name: "ChallengeNetworkingMocks", - dependencies: ["ChallengeNetworking"], - path: "Mocks", - swiftSettings: nonisolatedSettings - ), - .testTarget( - name: "ChallengeNetworkingTests", - dependencies: [ - "ChallengeNetworking", - "ChallengeNetworkingMocks", - .product(name: "ChallengeCoreMocks", package: "Core"), - ], - path: "Tests", - swiftSettings: nonisolatedSettings - ), - ] -) diff --git a/Libraries/SnapshotTestKit/Package.swift b/Libraries/SnapshotTestKit/Package.swift deleted file mode 100644 index bcae8764..00000000 --- a/Libraries/SnapshotTestKit/Package.swift +++ /dev/null @@ -1,28 +0,0 @@ -// swift-tools-version: 6.2 -import PackageDescription - -let mainActorSettings: [SwiftSetting] = [ - .defaultIsolation(MainActor.self), - .enableExperimentalFeature("ApproachableConcurrency"), -] - -let package = Package( - name: "ChallengeSnapshotTestKit", - platforms: [.iOS(.v17)], - products: [ - .library(name: "ChallengeSnapshotTestKit", targets: ["ChallengeSnapshotTestKit"]), - ], - dependencies: [ - .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.17.0"), - ], - targets: [ - .target( - name: "ChallengeSnapshotTestKit", - dependencies: [ - .product(name: "SnapshotTesting", package: "swift-snapshot-testing"), - ], - path: "Sources", - swiftSettings: mainActorSettings - ), - ] -) diff --git a/Project.swift b/Project.swift index eb712be5..118c07c1 100644 --- a/Project.swift +++ b/Project.swift @@ -1,4 +1,4 @@ import ProjectDescription import ProjectDescriptionHelpers -let project = App.project +let project = mainApp.project diff --git a/Shared/Resources/Package.swift b/Shared/Resources/Package.swift deleted file mode 100644 index 258e876e..00000000 --- a/Shared/Resources/Package.swift +++ /dev/null @@ -1,31 +0,0 @@ -// swift-tools-version: 6.2 -import PackageDescription - -let mainActorSettings: [SwiftSetting] = [ - .defaultIsolation(MainActor.self), - .enableExperimentalFeature("ApproachableConcurrency"), -] - -let package = Package( - name: "ChallengeResources", - platforms: [.iOS(.v17)], - products: [ - .library(name: "ChallengeResources", targets: ["ChallengeResources"]), - ], - dependencies: [ - .package(path: "../../Libraries/Core"), - ], - targets: [ - .target( - name: "ChallengeResources", - dependencies: [ - .product(name: "ChallengeCore", package: "Core"), - ], - path: "Sources", - resources: [ - .process("Resources"), - ], - swiftSettings: mainActorSettings - ), - ] -) diff --git a/Tuist/Package.swift b/Tuist/Package.swift index a636b868..9b08d918 100644 --- a/Tuist/Package.swift +++ b/Tuist/Package.swift @@ -1,54 +1,28 @@ -// swift-tools-version: 6.0 +// swift-tools-version: 6.2 import PackageDescription #if TUIST import ProjectDescription import ProjectDescriptionHelpers -let nonisolatedSettings: SettingsDictionary = projectBaseSettings.merging([ - "SWIFT_DEFAULT_ACTOR_ISOLATION": .string("nonisolated"), -]) { _, new in new } - -let snapshotTestKitSettings: SettingsDictionary = projectBaseSettings.merging([ - "ENABLE_TESTING_SEARCH_PATHS": "YES", -]) { _, new in new } - let packageSettings = PackageSettings( - productTypes: [ - "SnapshotTesting": .framework, - "SwiftMockServerBinary": .framework, - ], + productTypes: allExternalPackages.productTypes, baseSettings: .settings( configurations: BuildConfiguration.all ), - targetSettings: [ - // MainActor-default targets - "ChallengeCore": .settings(base: projectBaseSettings), - "ChallengeCoreMocks": .settings(base: projectBaseSettings), - "ChallengeCoreTests": .settings(base: projectBaseSettings), - "ChallengeDesignSystem": .settings(base: projectBaseSettings), - "ChallengeDesignSystemTests": .settings(base: projectBaseSettings), - "ChallengeResources": .settings(base: projectBaseSettings), - "ChallengeCharacter": .settings(base: projectBaseSettings), - "ChallengeCharacterTests": .settings(base: projectBaseSettings), - "ChallengeEpisode": .settings(base: projectBaseSettings), - "ChallengeEpisodeTests": .settings(base: projectBaseSettings), - "ChallengeHome": .settings(base: projectBaseSettings), - "ChallengeHomeTests": .settings(base: projectBaseSettings), - "ChallengeSystem": .settings(base: projectBaseSettings), - "ChallengeSystemTests": .settings(base: projectBaseSettings), - "ChallengeAppKit": .settings(base: projectBaseSettings), - "ChallengeAppKitTests": .settings(base: projectBaseSettings), - // Nonisolated targets - "ChallengeNetworking": .settings(base: nonisolatedSettings), - "ChallengeNetworkingMocks": .settings(base: nonisolatedSettings), - "ChallengeNetworkingTests": .settings(base: nonisolatedSettings), - // SnapshotTestKit - "ChallengeSnapshotTestKit": .settings(base: snapshotTestKitSettings), - ] + targetSettings: mainApp.packageTargetSettings ) -#endif +let package = PackageDescription.Package( + name: "ChallengePackages", + dependencies: allExternalPackages.map { + .package(url: $0.url, from: Version(stringLiteral: $0.version)) + } +) +#else +// Hardcoded fallback for `tuist install` which runs pure SPM +// without access to ProjectDescriptionHelpers. +// ⚠️ Keep URLs and versions in sync with ExternalPackages.swift. let package = Package( name: "ChallengePackages", dependencies: [ @@ -57,3 +31,4 @@ let package = Package( .package(url: "https://github.com/vjr2005/SwiftMockServer", from: "1.1.1"), ] ) +#endif diff --git a/Tuist/ProjectDescriptionHelpers/App.swift b/Tuist/ProjectDescriptionHelpers/App.swift deleted file mode 100644 index 4a07567f..00000000 --- a/Tuist/ProjectDescriptionHelpers/App.swift +++ /dev/null @@ -1,110 +0,0 @@ -import ProjectDescription - -/// Configuration and targets for the main app project. -public enum App { - /// Target reference for the main app target. - static var targetReference: TargetReference { - .target(appName) - } - - /// Target reference for the UI tests target. - static var uiTestsTargetReference: TargetReference { - .target("\(appName)UITests") - } - - /// App dependencies (modules that the app target depends on). - private static var dependencies: [TargetDependency] { - [appKitModule.targetDependency] - } - - // MARK: - Targets - - private static let infoPlist: [String: Plist.Value] = [ - "CFBundleLocalizations": ["en", "es"], - "UILaunchStoryboardName": "LaunchScreen", - "UISupportedInterfaceOrientations": [ - "UIInterfaceOrientationPortrait", - "UIInterfaceOrientationLandscapeLeft", - "UIInterfaceOrientationLandscapeRight", - "UIInterfaceOrientationPortraitUpsideDown", - ], - "UISupportedInterfaceOrientations~ipad": [ - "UIInterfaceOrientationPortrait", - "UIInterfaceOrientationPortraitUpsideDown", - "UIInterfaceOrientationLandscapeLeft", - "UIInterfaceOrientationLandscapeRight", - ], - "CFBundleURLTypes": [ - [ - "CFBundleURLSchemes": ["challenge"], - "CFBundleURLName": "com.app.Challenge", - ], - ], - ] - - private static var appTarget: Target { - .target( - name: appName, - destinations: destinations, - product: .app, - bundleId: "com.app.\(appName)", - deploymentTargets: developmentTarget, - infoPlist: .extendingDefault(with: infoPlist), - sources: ["App/Sources/**"], - resources: ["App/Sources/Resources/**"], - scripts: [SwiftLint.script(path: "App/Sources")], - dependencies: dependencies, - settings: .settings( - configurations: Environment.appTargetConfigurations, - defaultSettings: .recommended - ) - ) - } - - private static var uiTestsTarget: Target { - .target( - name: "\(appName)UITests", - destinations: destinations, - product: .uiTests, - bundleId: "com.app.\(appName)UITests", - deploymentTargets: developmentTarget, - infoPlist: .default, - sources: [ - "App/Tests/UI/**", - "App/Tests/Shared/**", - ], - resources: [ - "App/Tests/Shared/Fixtures/**", - "App/Tests/Shared/Resources/**", - ], - dependencies: [ - .target(name: appName), - .external(name: "SwiftMockServerBinary"), - ] - ) - } - - // MARK: - Project - - /// The main app project. - public static var project: Project { - Project( - name: appName, - options: .options( - automaticSchemesOptions: .disabled, - developmentRegion: "en", - disableBundleAccessors: true, - disableSynthesizedResourceAccessors: true - ), - packages: Modules.packageReferences, - settings: .settings( - base: projectBaseSettings.merging([ - "SWIFT_EMIT_LOC_STRINGS": .string("YES"), - ]) { _, new in new }, - configurations: BuildConfiguration.all - ), - targets: [appTarget, uiTestsTarget], - schemes: AppScheme.allSchemes() + [AppScheme.uiTestsScheme()] - ) - } -} diff --git a/Tuist/ProjectDescriptionHelpers/AppScheme.swift b/Tuist/ProjectDescriptionHelpers/AppScheme.swift deleted file mode 100644 index 5ef1ff43..00000000 --- a/Tuist/ProjectDescriptionHelpers/AppScheme.swift +++ /dev/null @@ -1,66 +0,0 @@ -import ProjectDescription - -/// Factory for creating app schemes consistently. -public enum AppScheme { - /// Creates a scheme for the given environment. - /// - Parameters: - /// - environment: The target environment. - /// - includeTests: Whether to include test targets in the scheme. - /// - Returns: A configured Scheme. - static func create( - environment: Environment, - includeTests: Bool = false - ) -> Scheme { - let appTarget = App.targetReference - - return .scheme( - name: environment.schemeName, - buildAction: .buildAction(targets: [appTarget]), - testAction: includeTests ? .testPlans(["Challenge.xctestplan"]) : nil, - runAction: .runAction( - configuration: environment.debugConfigurationName, - executable: appTarget - ), - archiveAction: .archiveAction(configuration: environment.releaseConfigurationName), - profileAction: .profileAction( - configuration: environment.releaseConfigurationName, - executable: appTarget - ), - analyzeAction: .analyzeAction(configuration: environment.debugConfigurationName) - ) - } - - /// Creates the UI tests scheme. - /// - Returns: A configured Scheme for UI tests. - public static func uiTestsScheme() -> Scheme { - let appTarget = App.targetReference - - return .scheme( - name: "\(appName)UITests", - buildAction: .buildAction(targets: [appTarget, App.uiTestsTargetReference]), - testAction: .targets( - [ - .testableTarget( - target: App.uiTestsTargetReference - ), - ], - options: .options( - preferredScreenCaptureFormat: .screenRecording, - coverage: true, - codeCoverageTargets: [appTarget] - ) - ) - ) - } - - /// Creates all app schemes for all environments. - /// - Returns: Array of schemes for all environments. - public static func allSchemes() -> [Scheme] { - Environment.allCases.map { environment in - create( - environment: environment, - includeTests: environment == .dev - ) - } - } -} diff --git a/Tuist/ProjectDescriptionHelpers/Config.swift b/Tuist/ProjectDescriptionHelpers/Config.swift index 1893c186..7a5d3eb1 100644 --- a/Tuist/ProjectDescriptionHelpers/Config.swift +++ b/Tuist/ProjectDescriptionHelpers/Config.swift @@ -1,27 +1,20 @@ -import Foundation import ProjectDescription -public let appName = "Challenge" +private let swiftVersion = "6.2" +private let iosMajorVersion = "17" -/// Absolute path to the workspace root directory. -/// Computed from `#file` (Tuist/ProjectDescriptionHelpers/Config.swift → 3 levels up). -let workspaceRoot: String = URL(fileURLWithPath: #file) - .deletingLastPathComponent() // Config.swift - .deletingLastPathComponent() // ProjectDescriptionHelpers - .deletingLastPathComponent() // Tuist - .path - -let swiftVersion = "6.2" - -let developmentTarget: DeploymentTargets = .iOS("17.0") - -let destinations: ProjectDescription.Destinations = [.iPhone, .iPad] - -/// Base build settings shared across all targets. -public let projectBaseSettings: SettingsDictionary = [ - "SWIFT_VERSION": .string(swiftVersion), - "SWIFT_STRICT_CONCURRENCY": .string("complete"), - "SWIFT_APPROACHABLE_CONCURRENCY": .string("YES"), - "SWIFT_DEFAULT_ACTOR_ISOLATION": .string("MainActor"), - "ENABLE_USER_SCRIPT_SANDBOXING": .string("NO"), -] +/// Single source of truth for project-wide configuration. +public let projectConfig = ProjectConfig( + appName: "Challenge", + swiftToolsVersion: swiftVersion, + iosMajorVersion: iosMajorVersion, + destinations: [.iPhone, .iPad], + developmentTarget: .iOS("\(iosMajorVersion).0"), + baseSettings: [ + "SWIFT_VERSION": .string(swiftVersion), + "SWIFT_STRICT_CONCURRENCY": .string("complete"), + "SWIFT_APPROACHABLE_CONCURRENCY": .string("YES"), + "SWIFT_DEFAULT_ACTOR_ISOLATION": .string("MainActor"), + "ENABLE_USER_SCRIPT_SANDBOXING": .string("NO"), + ] +) diff --git a/Tuist/ProjectDescriptionHelpers/Environment.swift b/Tuist/ProjectDescriptionHelpers/Environment.swift index 05d7c958..4503f6dc 100644 --- a/Tuist/ProjectDescriptionHelpers/Environment.swift +++ b/Tuist/ProjectDescriptionHelpers/Environment.swift @@ -9,9 +9,9 @@ enum Environment: String, CaseIterable { /// Display name for the scheme. var schemeName: String { switch self { - case .dev: "\(appName) (Dev)" - case .staging: "\(appName) (Staging)" - case .prod: "\(appName) (Prod)" + case .dev: "\(projectConfig.appName) (Dev)" + case .staging: "\(projectConfig.appName) (Staging)" + case .prod: "\(projectConfig.appName) (Prod)" } } @@ -26,7 +26,7 @@ enum Environment: String, CaseIterable { /// Full bundle identifier for each environment. var bundleId: String { - "com.app.\(appName)\(bundleIdSuffix)" + "com.app.\(projectConfig.appName)\(bundleIdSuffix)" } /// App icon asset name for each environment. diff --git a/Tuist/ProjectDescriptionHelpers/ExternalPackages.swift b/Tuist/ProjectDescriptionHelpers/ExternalPackages.swift new file mode 100644 index 00000000..d5cc25d2 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/ExternalPackages.swift @@ -0,0 +1,32 @@ +/// External SPM dependencies used by this project. + +import ProjectDescription + +public let snapshotTestingPackage = ExternalPackage( + productName: "SnapshotTesting", + url: "https://github.com/pointfreeco/swift-snapshot-testing", + version: "1.17.0", + productType: .framework +) + +public let lottiePackage = ExternalPackage( + productName: "Lottie", + url: "https://github.com/airbnb/lottie-ios", + version: "4.6.0", + productType: .framework +) + +public let swiftMockServerPackage = ExternalPackage( + productName: "SwiftMockServerBinary", + url: "https://github.com/vjr2005/SwiftMockServer", + version: "1.1.1", + productType: .framework +) + +/// All external SPM packages. Used by `Package.swift` to derive +/// `PackageSettings.productTypes` and `Package.dependencies`. +public let allExternalPackages: [ExternalPackage] = [ + snapshotTestingPackage, + lottiePackage, + swiftMockServerPackage, +] diff --git a/Tuist/ProjectDescriptionHelpers/MainApp.swift b/Tuist/ProjectDescriptionHelpers/MainApp.swift new file mode 100644 index 00000000..929367f9 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/MainApp.swift @@ -0,0 +1,45 @@ +import ProjectDescription + +/// The app instance shared across all manifest files. +public let mainApp = App( + name: projectConfig.appName, + bundleId: "com.app.\(projectConfig.appName)", + destinations: projectConfig.destinations, + developmentTarget: projectConfig.developmentTarget, + baseSettings: projectConfig.baseSettings, + infoPlist: [ + "CFBundleLocalizations": ["en", "es"], + "UILaunchStoryboardName": "LaunchScreen", + "UISupportedInterfaceOrientations": [ + "UIInterfaceOrientationPortrait", + "UIInterfaceOrientationLandscapeLeft", + "UIInterfaceOrientationLandscapeRight", + "UIInterfaceOrientationPortraitUpsideDown", + ], + "UISupportedInterfaceOrientations~ipad": [ + "UIInterfaceOrientationPortrait", + "UIInterfaceOrientationPortraitUpsideDown", + "UIInterfaceOrientationLandscapeLeft", + "UIInterfaceOrientationLandscapeRight", + ], + "CFBundleURLTypes": [ + [ + "CFBundleURLSchemes": ["challenge"], + "CFBundleURLName": "com.app.Challenge", + ], + ], + ], + modules: [ + coreModule, + networkingModule, + snapshotTestKitModule, + designSystemModule, + resourcesModule, + characterModule, + episodeModule, + homeModule, + systemModule, + appKitModule, + ], + entryModule: appKitModule +) diff --git a/Tuist/ProjectDescriptionHelpers/Module.swift b/Tuist/ProjectDescriptionHelpers/Module.swift deleted file mode 100644 index a35a7d25..00000000 --- a/Tuist/ProjectDescriptionHelpers/Module.swift +++ /dev/null @@ -1,84 +0,0 @@ -import Foundation -import ProjectDescription - -/// A module representing an SPM local package. -public struct Module: @unchecked Sendable { - let directory: String - let name: String - let hasMocks: Bool - let includeInCoverage: Bool - - // MARK: - Computed Properties - - /// Package reference for Project.swift packages array. - var packageReference: Package { - .package(path: Path(stringLiteral: directory)) - } - - /// Target dependency for consuming targets. - var targetDependency: TargetDependency { - .package(product: name) - } - - /// Mocks dependency for consuming targets. - var mocksTargetDependency: TargetDependency { - .package(product: "\(name)Mocks") - } - - // MARK: - Private Helpers - - /// Checks if a folder contains any files (searches recursively). - private static func folderContainsFiles(at path: String, withExtension ext: String? = nil) -> Bool { - let fileManager = FileManager.default - - guard let enumerator = fileManager.enumerator(atPath: path) else { - return false - } - - while let file = enumerator.nextObject() as? String { - if let ext { - if file.hasSuffix(ext) { - return true - } - } else { - var isDirectory: ObjCBool = false - let fullPath = "\(path)/\(file)" - if fileManager.fileExists(atPath: fullPath, isDirectory: &isDirectory), !isDirectory.boolValue { - return true - } - } - } - - return false - } - - /// Checks if a Mocks folder exists with Swift files. - private static func hasMocksFolder(directory: String) -> Bool { - folderContainsFiles(at: "\(workspaceRoot)/\(directory)/Mocks", withExtension: ".swift") - } - - // MARK: - Factory - - /// Creates a module metadata holder for an SPM local package. - /// - /// - Parameters: - /// - directory: The module's directory relative to the workspace root (e.g., "Libraries/Core", "Features/Character", "AppKit"). - /// The last path component is used as the module name (e.g., "Core" → target `ChallengeCore`). - /// - includeInCoverage: Whether the module's source target should be included in code coverage. - /// Defaults to `true`. Set to `false` for infrastructure modules without meaningful source (e.g., Resources, SnapshotTestKit). - static func create(directory: String, includeInCoverage: Bool = true) -> Module { - let components = directory.split(separator: "/") - guard let last = components.last else { - fatalError("Module directory must not be empty") - } - let shortName = String(last) - let targetName = "\(appName)\(shortName)" - - return Module( - directory: directory, - name: targetName, - hasMocks: hasMocksFolder(directory: directory), - includeInCoverage: includeInCoverage - ) - } -} diff --git a/Tuist/ProjectDescriptionHelpers/ModuleKit/App.swift b/Tuist/ProjectDescriptionHelpers/ModuleKit/App.swift new file mode 100644 index 00000000..e27df2a1 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/ModuleKit/App.swift @@ -0,0 +1,195 @@ +import ProjectDescription + +/// Configuration and targets for the main app project. +public struct App: @unchecked Sendable { + // MARK: - Configuration + + public let name: String + public let bundleId: String + public let appDestinations: ProjectDescription.Destinations + public let appDeploymentTarget: DeploymentTargets + public let baseSettings: SettingsDictionary + public let infoPlist: [String: Plist.Value] + public let modules: [any ModuleContract] + public let entryModule: any ModuleContract + + public init( + name: String, + bundleId: String, + destinations: ProjectDescription.Destinations, + developmentTarget: DeploymentTargets, + baseSettings: SettingsDictionary, + infoPlist: [String: Plist.Value], + modules: [any ModuleContract], + entryModule: any ModuleContract + ) { + self.name = name + self.bundleId = bundleId + self.appDestinations = destinations + self.appDeploymentTarget = developmentTarget + self.baseSettings = baseSettings + self.infoPlist = infoPlist + self.modules = modules + self.entryModule = entryModule + } + + // MARK: - Module Aggregation + + /// Package references for the project. SPM: one per module. Framework: empty. + var packages: [Package] { + modules.compactMap(\.packageReference) + } + + /// Framework targets for the project. SPM: empty. Framework: all module targets. + var moduleTargets: [Target] { + modules.flatMap(\.targets) + } + + /// Per-module schemes. SPM: empty. Framework: one scheme per module. + var moduleSchemes: [Scheme] { + modules.flatMap(\.schemes) + } + + /// Test action for the Dev scheme. Uses a test plan that supports mixed module strategies. + var devTestAction: TestAction { + ModuleAggregation.aggregateTestAction(modules: modules, appTargetReference: targetReference) + } + + /// Code coverage mode for workspace autogenerated schemes. + public var coverageMode: Workspace.GenerationOptions.AutogeneratedWorkspaceSchemes.CodeCoverageMode { + ModuleAggregation.aggregateCoverageMode(modules: modules, appTargetReference: targetReference) + } + + /// Per-target build settings for `Tuist/Package.swift`. + /// Aggregated from each module. SPM: one entry per detected target. Framework: empty. + public var packageTargetSettings: [String: Settings] { + var result: [String: Settings] = [:] + for module in modules { + result.merge(module.packageTargetSettings) { _, new in new } + } + return result + } + + // MARK: - Target References + + var targetReference: TargetReference { + .target(name) + } + + var uiTestsTargetReference: TargetReference { + .target("\(name)UITests") + } + + // MARK: - Targets + + private var appTarget: Target { + .target( + name: name, + destinations: appDestinations, + product: .app, + bundleId: bundleId, + deploymentTargets: appDeploymentTarget, + infoPlist: .extendingDefault(with: infoPlist), + sources: ["App/Sources/**"], + resources: ["App/Sources/Resources/**"], + scripts: sourceTargetScripts(path: "App/Sources"), + dependencies: [entryModule.targetDependency], + settings: .settings( + configurations: Environment.appTargetConfigurations, + defaultSettings: .recommended + ) + ) + } + + private var uiTestsTarget: Target { + .target( + name: "\(name)UITests", + destinations: appDestinations, + product: .uiTests, + bundleId: "\(bundleId)UITests", + deploymentTargets: appDeploymentTarget, + infoPlist: .default, + sources: [ + "App/Tests/UI/**", + "App/Tests/Shared/**", + ], + resources: [ + "App/Tests/Shared/Fixtures/**", + "App/Tests/Shared/Resources/**", + ], + dependencies: [ + .target(name: name), + .external(name: "SwiftMockServerBinary"), + ] + ) + } + + // MARK: - Schemes + + private func scheme(for environment: Environment, includeTests: Bool = false) -> Scheme { + .scheme( + name: environment.schemeName, + buildAction: .buildAction(targets: [targetReference]), + testAction: includeTests ? devTestAction : nil, + runAction: .runAction( + configuration: environment.debugConfigurationName, + executable: targetReference + ), + archiveAction: .archiveAction(configuration: environment.releaseConfigurationName), + profileAction: .profileAction( + configuration: environment.releaseConfigurationName, + executable: targetReference + ), + analyzeAction: .analyzeAction(configuration: environment.debugConfigurationName) + ) + } + + private var uiTestsScheme: Scheme { + .scheme( + name: "\(name)UITests", + buildAction: .buildAction(targets: [targetReference, uiTestsTargetReference]), + testAction: .targets( + [ + .testableTarget( + target: uiTestsTargetReference + ), + ], + options: .options( + preferredScreenCaptureFormat: .screenRecording, + coverage: true, + codeCoverageTargets: [targetReference] + ) + ) + ) + } + + private var allSchemes: [Scheme] { + Environment.allCases.map { environment in + scheme(for: environment, includeTests: environment == .dev) + } + } + + // MARK: - Project + + /// The main app project. + public var project: Project { + Project( + name: name, + options: .options( + automaticSchemesOptions: .disabled, + developmentRegion: "en", + disableBundleAccessors: false, + disableSynthesizedResourceAccessors: true + ), + packages: packages, + settings: .settings( + base: baseSettings.merging([ + "SWIFT_EMIT_LOC_STRINGS": .string("YES"), + ]) { _, new in new }, + configurations: BuildConfiguration.all + ), + targets: [appTarget, uiTestsTarget] + moduleTargets, + schemes: allSchemes + [uiTestsScheme] + moduleSchemes + ) + } +} diff --git a/Tuist/ProjectDescriptionHelpers/BuildConfiguration.swift b/Tuist/ProjectDescriptionHelpers/ModuleKit/BuildConfiguration.swift similarity index 100% rename from Tuist/ProjectDescriptionHelpers/BuildConfiguration.swift rename to Tuist/ProjectDescriptionHelpers/ModuleKit/BuildConfiguration.swift diff --git a/Tuist/ProjectDescriptionHelpers/ModuleKit/Generators/PackageSwiftGenerator.swift b/Tuist/ProjectDescriptionHelpers/ModuleKit/Generators/PackageSwiftGenerator.swift new file mode 100644 index 00000000..d5c4a1bf --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/ModuleKit/Generators/PackageSwiftGenerator.swift @@ -0,0 +1,348 @@ +import Foundation +import ProjectDescription + +/// Generates `Package.swift` files for SPM local packages. +/// +/// Called from `SPMModule.init` during manifest evaluation to auto-generate +/// the `Package.swift` that defines each module's targets and dependencies. +/// Follows the same manifest-time side-effect pattern as `TestPlanGenerator`. +enum PackageSwiftGenerator { + // swiftlint:disable:next function_body_length + static func generate( + directory: String, + name: String, + dependencies: [ModuleDependency], + testDependencies: [ModuleDependency], + snapshotTestDependencies: [ModuleDependency], + settingsOverrides: SettingsDictionary, + fileSystem: ModuleFileSystem, + config: ProjectConfig + ) { + let isNonisolated = isNonisolatedModule(settingsOverrides: settingsOverrides) + let settingsVarName = isNonisolated ? "nonisolatedSettings" : "mainActorSettings" + + let allDeps = dependencies + testDependencies + snapshotTestDependencies + let packageDeps = collectPackageDependencies(from: allDeps, sourceDirectory: directory) + let sourceTargetDeps = dependencies.map { targetDependencyEntry(for: $0) } + + let hasTests = fileSystem.hasUnitTests || fileSystem.hasSnapshotTests + let testTargetDeps = hasTests + ? buildTestTargetDependencies( + name: name, + hasMocks: fileSystem.hasMocks, + testDependencies: testDependencies, + snapshotTestDependencies: snapshotTestDependencies + ) + : [] + + var lines: [String] = [] + + // Header + lines.append("// swift-tools-version: \(config.swiftToolsVersion)") + lines.append("import PackageDescription") + lines.append("") + + // Swift settings + appendSwiftSettings(to: &lines, isNonisolated: isNonisolated) + lines.append("") + + // Package definition + lines.append("let package = Package(") + lines.append("\tname: \"\(name)\",") + lines.append("\tplatforms: [.iOS(.v\(config.iosMajorVersion))],") + + // Products + appendProducts(to: &lines, name: name, hasMocks: fileSystem.hasMocks) + + // Package-level dependencies + if !packageDeps.isEmpty { + lines.append("\tdependencies: [") + let depLines = packageDeps.map { "\t\t\($0)" } + lines.append(depLines.joined(separator: ",\n")) + lines.append("\t],") + } + + // Targets + lines.append("\ttargets: [") + appendSourceTarget( + to: &lines, + name: name, + sourceTargetDeps: sourceTargetDeps, + hasResources: fileSystem.hasResources, + settingsVarName: settingsVarName + ) + + if fileSystem.hasMocks { + appendMocksTarget(to: &lines, name: name, settingsVarName: settingsVarName) + } + + if hasTests { + appendTestTarget( + to: &lines, + name: name, + testTargetDeps: testTargetDeps, + fileSystem: fileSystem, + settingsVarName: settingsVarName + ) + } + + lines.append("\t]") + lines.append(")") + lines.append("") + + let content = lines.joined(separator: "\n") + let path = "\(workspaceRoot)/\(directory)/Package.swift" + do { + try content.write(toFile: path, atomically: true, encoding: .utf8) + } catch { + fatalError("Failed to write Package.swift for module '\(name)' at \(path): \(error)") + } + } +} + +// MARK: - Detection + +private extension PackageSwiftGenerator { + static func isNonisolatedModule(settingsOverrides: SettingsDictionary) -> Bool { + if case let .string(value) = settingsOverrides["SWIFT_DEFAULT_ACTOR_ISOLATION"], value == "nonisolated" { + return true + } + return false + } +} + +// MARK: - Path Computation + +private extension PackageSwiftGenerator { + /// Computes relative path from source directory to dependency directory. + /// + /// Example: from `"Features/Character"` to `"Libraries/Core"` → `"../../Libraries/Core"`. + static func relativePath(from source: String, to destination: String) -> String { + let sourceComponents = source.split(separator: "/").map(String.init) + let destComponents = destination.split(separator: "/").map(String.init) + + var commonLength = 0 + for index in 0.. String { + String(directory.split(separator: "/").last ?? Substring(directory)) + } +} + +// MARK: - Dependency Collection + +private extension PackageSwiftGenerator { + /// Collects unique package-level dependency entries, preserving order of first appearance. + static func collectPackageDependencies( + from allDeps: [ModuleDependency], + sourceDirectory: String + ) -> [String] { + var seen: Set = [] + var result: [String] = [] + + for dep in allDeps { + switch dep { + case let .module(module): + if seen.insert(module.directory).inserted { + let path = relativePath(from: sourceDirectory, to: module.directory) + result.append(".package(path: \"\(path)\")") + } + case let .moduleMocks(module): + if seen.insert(module.directory).inserted { + let path = relativePath(from: sourceDirectory, to: module.directory) + result.append(".package(path: \"\(path)\")") + } + case let .external(package): + if seen.insert(package.url).inserted { + result.append(".package(url: \"\(package.url)\", from: \"\(package.version)\")") + } + } + } + + return result + } + + /// Generates a target-level dependency entry string. + static func targetDependencyEntry(for dep: ModuleDependency) -> String { + switch dep { + case let .module(module): + let pkgId = packageIdentity(for: module.directory) + return ".product(name: \"\(module.name)\", package: \"\(pkgId)\")" + case let .moduleMocks(module): + let pkgId = packageIdentity(for: module.directory) + return ".product(name: \"\(module.name)Mocks\", package: \"\(pkgId)\")" + case let .external(package): + return ".product(name: \"\(package.productName)\", package: \"\(package.packageIdentity)\")" + } + } + + /// Builds deduplicated test target dependencies from test and snapshot dependency arrays. + static func buildTestTargetDependencies( + name: String, + hasMocks: Bool, + testDependencies: [ModuleDependency], + snapshotTestDependencies: [ModuleDependency] + ) -> [String] { + var result: [String] = ["\"\(name)\""] + + if hasMocks { + result.append("\"\(name)Mocks\"") + } + + var seenProducts: Set = [] + for dep in testDependencies + snapshotTestDependencies { + let productKey = targetDependencyProductKey(for: dep) + if seenProducts.insert(productKey).inserted { + result.append(targetDependencyEntry(for: dep)) + } + } + + return result + } + + /// Returns a unique key for deduplication of target-level dependencies. + static func targetDependencyProductKey(for dep: ModuleDependency) -> String { + switch dep { + case let .module(module): + module.name + case let .moduleMocks(module): + "\(module.name)Mocks" + case let .external(package): + package.productName + } + } +} + +// MARK: - Line Builders + +private extension PackageSwiftGenerator { + static func appendSwiftSettings(to lines: inout [String], isNonisolated: Bool) { + if isNonisolated { + lines.append("let nonisolatedSettings: [SwiftSetting] = [") + let settings = [ + "\t.enableExperimentalFeature(\"ApproachableConcurrency\")" + ] + lines.append(settings.joined(separator: ",\n")) + lines.append("]") + } else { + lines.append("let mainActorSettings: [SwiftSetting] = [") + let settings = [ + "\t.defaultIsolation(MainActor.self)", + "\t.enableExperimentalFeature(\"ApproachableConcurrency\")" + ] + lines.append(settings.joined(separator: ",\n")) + lines.append("]") + } + } + + static func appendProducts(to lines: inout [String], name: String, hasMocks: Bool) { + lines.append("\tproducts: [") + var products = [ + "\t\t.library(name: \"\(name)\", targets: [\"\(name)\"])" + ] + if hasMocks { + products.append("\t\t.library(name: \"\(name)Mocks\", targets: [\"\(name)Mocks\"])") + } + lines.append(products.joined(separator: ",\n")) + lines.append("\t],") + } + + static func appendSourceTarget( + to lines: inout [String], + name: String, + sourceTargetDeps: [String], + hasResources: Bool, + settingsVarName: String + ) { + lines.append("\t\t.target(") + lines.append("\t\t\tname: \"\(name)\",") + + if !sourceTargetDeps.isEmpty { + lines.append("\t\t\tdependencies: [") + let depsLines = sourceTargetDeps.map { "\t\t\t\t\($0)" } + lines.append(depsLines.joined(separator: ",\n")) + lines.append("\t\t\t],") + } + + lines.append("\t\t\tpath: \"Sources\",") + + if hasResources { + lines.append("\t\t\tresources: [") + let resources = [ + "\t\t\t\t.process(\"Resources\")" + ] + lines.append(resources.joined(separator: ",\n")) + lines.append("\t\t\t],") + } + + lines.append("\t\t\tswiftSettings: \(settingsVarName)") + lines.append("\t\t),") + } + + static func appendMocksTarget(to lines: inout [String], name: String, settingsVarName: String) { + lines.append("\t\t.target(") + lines.append("\t\t\tname: \"\(name)Mocks\",") + lines.append("\t\t\tdependencies: [\"\(name)\"],") + lines.append("\t\t\tpath: \"Mocks\",") + lines.append("\t\t\tswiftSettings: \(settingsVarName)") + lines.append("\t\t),") + } + + static func appendTestTarget( + to lines: inout [String], + name: String, + testTargetDeps: [String], + fileSystem: ModuleFileSystem, + settingsVarName: String + ) { + lines.append("\t\t.testTarget(") + lines.append("\t\t\tname: \"\(name)Tests\",") + lines.append("\t\t\tdependencies: [") + + let depLines = testTargetDeps.map { "\t\t\t\t\($0)" } + lines.append(depLines.joined(separator: ",\n")) + lines.append("\t\t\t],") + + lines.append("\t\t\tpath: \"Tests\",") + + let exclusions = fileSystem.snapshotExclusions + if !exclusions.isEmpty { + lines.append("\t\t\texclude: [") + let exLines = exclusions.map { "\t\t\t\t\"\($0)\"" } + lines.append(exLines.joined(separator: ",\n")) + lines.append("\t\t\t],") + } + + var testResources: [String] = [] + if fileSystem.hasSharedFixtures { + testResources.append(".process(\"Shared/Fixtures\")") + } + if fileSystem.hasSharedResources { + testResources.append(".process(\"Shared/Resources\")") + } + if !testResources.isEmpty { + lines.append("\t\t\tresources: [") + let resLines = testResources.map { "\t\t\t\t\($0)" } + lines.append(resLines.joined(separator: ",\n")) + lines.append("\t\t\t],") + } + + lines.append("\t\t\tswiftSettings: \(settingsVarName)") + lines.append("\t\t),") + } +} diff --git a/Tuist/ProjectDescriptionHelpers/ModuleKit/Generators/TestPlan.swift b/Tuist/ProjectDescriptionHelpers/ModuleKit/Generators/TestPlan.swift new file mode 100644 index 00000000..96a77058 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/ModuleKit/Generators/TestPlan.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Type-safe model for the `.xctestplan` JSON schema. +/// +/// Used by ``TestPlanGenerator`` to build the test plan structure and +/// serialize it with `JSONEncoder` instead of untyped dictionaries. + +// MARK: - Root + +struct TestPlan: Encodable { + let configurations: [TestPlanConfiguration] + let defaultOptions: TestPlanDefaultOptions + let testTargets: [TestPlanTestTargetEntry] + let version: Int +} + +// MARK: - Configuration + +struct TestPlanConfiguration: Encodable { + let id: String + let name: String + let options: TestPlanEmptyObject + + init(name: String) { + self.id = UUID().uuidString + self.name = name + self.options = TestPlanEmptyObject() + } +} + +/// Encodes to an empty JSON object `{}`. +struct TestPlanEmptyObject: Encodable {} + +// MARK: - Default Options + +struct TestPlanDefaultOptions: Encodable { + let codeCoverage: TestPlanCodeCoverage +} + +struct TestPlanCodeCoverage: Encodable { + let targets: [TestPlanCoverageTarget] +} + +struct TestPlanCoverageTarget: Encodable { + let containerPath: String + let identifier: String + let name: String +} + +// MARK: - Test Targets + +struct TestPlanTestTargetEntry: Encodable { + let target: TestPlanTestTarget +} + +struct TestPlanTestTarget: Encodable { + let containerPath: String + let identifier: String + let name: String +} diff --git a/Tuist/ProjectDescriptionHelpers/ModuleKit/Generators/TestPlanGenerator.swift b/Tuist/ProjectDescriptionHelpers/ModuleKit/Generators/TestPlanGenerator.swift new file mode 100644 index 00000000..fe0e8f15 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/ModuleKit/Generators/TestPlanGenerator.swift @@ -0,0 +1,108 @@ +import Foundation + +/// Generates an `.xctestplan` JSON file from module definitions. +/// +/// Used by `ModuleAggregation` to auto-generate the test plan that aggregates +/// all module test targets and code coverage targets. Supports any mix of +/// SPM and Framework modules via `ModuleContract.containerPath`. +/// +/// **Coverage filtering:** Uses `codeCoverage.targets` to tell Xcode to +/// "Gather coverage for some targets." Only modules with +/// `includeInCoverage == true` are listed. All targets reference +/// the xcodeproj container, which works for both framework targets +/// and SPM local packages resolved through the project. +enum TestPlanGenerator { + /// Generates the `.xctestplan` file and returns its filename. + /// + /// - Parameters: + /// - appName: The app target name (e.g., `"Challenge"`). + /// - modules: All project modules to include in the test plan. + /// - Returns: The test plan filename (e.g., `"Challenge.xctestplan"`). + static func generate( + appName: String, + modules: [any ModuleContract] + ) -> String { + let testPlanName = "\(appName).xctestplan" + + let appContainerPath = "container:\(appName).xcodeproj" + + var coverageTargets: [TestPlanCoverageTarget] = [ + TestPlanCoverageTarget(containerPath: appContainerPath, identifier: appName, name: appName), + ] + var testTargets: [TestPlanTestTargetEntry] = [] + + for module in modules { + if module.includeInCoverage { + coverageTargets.append( + TestPlanCoverageTarget( + containerPath: module.containerPath, + identifier: module.name, + name: module.name + ) + ) + } + + let fileSystem = ModuleFileSystem(directory: module.directory, appName: appName) + if fileSystem.hasUnitTests { + testTargets.append( + TestPlanTestTargetEntry( + target: TestPlanTestTarget( + containerPath: module.containerPath, + identifier: "\(module.name)Tests", + name: "\(module.name)Tests" + ) + ) + ) + } + + // Framework modules have separate snapshot test targets. + // SPM modules merge snapshots into the main test target (already covered above). + if fileSystem.hasSnapshotTests, module.packageReference == nil { + let snapshotTestsName = "\(module.name)SnapshotTests" + testTargets.append( + TestPlanTestTargetEntry( + target: TestPlanTestTarget( + containerPath: module.containerPath, + identifier: snapshotTestsName, + name: snapshotTestsName + ) + ) + ) + } + } + + let testPlan = TestPlan( + configurations: [ + TestPlanConfiguration(name: "Test Scheme Action"), + ], + defaultOptions: TestPlanDefaultOptions( + codeCoverage: TestPlanCodeCoverage(targets: coverageTargets) + ), + testTargets: testTargets, + version: 1 + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + + let data: Data + do { + data = try encoder.encode(testPlan) + } catch { + fatalError("Failed to encode test plan '\(testPlanName)': \(error)") + } + + guard let json = String(data: data, encoding: .utf8) else { + fatalError("Failed to encode test plan '\(testPlanName)' as UTF-8") + } + + let path = "\(workspaceRoot)/\(testPlanName)" + do { + try (json + "\n").write(toFile: path, atomically: true, encoding: .utf8) + } catch { + fatalError("Failed to write test plan at \(path): \(error)") + } + + return testPlanName + } +} diff --git a/Tuist/ProjectDescriptionHelpers/ModuleKit/ProjectConfig.swift b/Tuist/ProjectDescriptionHelpers/ModuleKit/ProjectConfig.swift new file mode 100644 index 00000000..66cb6ab6 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/ModuleKit/ProjectConfig.swift @@ -0,0 +1,27 @@ +import ProjectDescription + +/// Bundles all project-wide settings into a single typed value. +public struct ProjectConfig: @unchecked Sendable { + public let appName: String + public let swiftToolsVersion: String + public let iosMajorVersion: String + public let destinations: Destinations + public let developmentTarget: DeploymentTargets + public let baseSettings: SettingsDictionary + + public init( + appName: String, + swiftToolsVersion: String, + iosMajorVersion: String, + destinations: Destinations, + developmentTarget: DeploymentTargets, + baseSettings: SettingsDictionary + ) { + self.appName = appName + self.swiftToolsVersion = swiftToolsVersion + self.iosMajorVersion = iosMajorVersion + self.destinations = destinations + self.developmentTarget = developmentTarget + self.baseSettings = baseSettings + } +} diff --git a/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ExternalPackage.swift b/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ExternalPackage.swift new file mode 100644 index 00000000..e95a554c --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ExternalPackage.swift @@ -0,0 +1,49 @@ +import Foundation +import ProjectDescription + +/// Metadata for an external SPM dependency (URL + version). +/// +/// Used by `ModuleDependency.external` to carry enough information +/// for both Tuist target resolution and `Package.swift` generation. +public struct ExternalPackage: Sendable { + /// The SPM product name (e.g., `"Lottie"`, `"SnapshotTesting"`). + public let productName: String + + /// The package repository URL. + public let url: String + + /// The minimum version requirement (used with `.upToNextMajor`). + public let version: String + + /// Tuist product type override (e.g., `.framework`). + /// When `nil`, Tuist uses the default product type for the package. + public let productType: Product? + + /// Package identity derived from the URL's last path component. + /// + /// Example: `"https://github.com/airbnb/lottie-ios"` → `"lottie-ios"`. + var packageIdentity: String { + URL(string: url)?.lastPathComponent ?? productName + } + + public init(productName: String, url: String, version: String, productType: Product? = nil) { + self.productName = productName + self.url = url + self.version = version + self.productType = productType + } +} + +// MARK: - Collection Helpers + +extension [ExternalPackage] { + /// Product type overrides for `PackageSettings`. + /// Only includes packages with an explicit `productType`. + public var productTypes: [String: Product] { + reduce(into: [:]) { result, package in + if let productType = package.productType { + result[package.productName] = productType + } + } + } +} diff --git a/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/FrameworkModule.swift b/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/FrameworkModule.swift new file mode 100644 index 00000000..fd0b0e03 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/FrameworkModule.swift @@ -0,0 +1,275 @@ +import ProjectDescription + +/// Module implementation that integrates as a framework target in the root project. +/// +/// Each module produces framework targets (source, mocks, unit tests, snapshot tests) +/// and a per-module scheme. Dependencies are resolved from the init parameters. +/// Tests run via `.targets(...)` with explicit testable targets and coverage. +public struct FrameworkModule: ModuleContract, @unchecked Sendable { + public let directory: String + public let name: String + public let includeInCoverage: Bool + + // MARK: - Framework: Stored + + public let targets: [Target] + public let schemes: [Scheme] + public let testableTargets: [TestableTarget] + public let codeCoverageTargets: [TargetReference] + + // MARK: - Framework: Computed (derived from name) + + public var targetDependency: TargetDependency { .target(name: name) } + public var mocksTargetDependency: TargetDependency { .target(name: "\(name)Mocks") } + public let packageReference: Package? = nil + public let packageTargetSettings: [String: Settings] = [:] + public let containerPath: String + + // MARK: - Init + + public init( + directory: String, + dependencies: [ModuleDependency] = [], + testDependencies: [ModuleDependency] = [], + snapshotTestDependencies: [ModuleDependency] = [], + includeInCoverage: Bool = true, + settingsOverrides: SettingsDictionary = [:], + config: ProjectConfig = projectConfig + ) { + let fileSystem = ModuleFileSystem(directory: directory, appName: config.appName) + let name = fileSystem.targetName + self.directory = directory + self.name = name + self.includeInCoverage = includeInCoverage + self.containerPath = "container:\(config.appName).xcodeproj" + + let pathPrefix = "\(directory)/" + let settings: Settings = .settings( + base: config.baseSettings.merging(settingsOverrides) { _, new in new } + ) + + var targets: [Target] = [ + Self.makeFrameworkTarget( + name: name, + pathPrefix: pathPrefix, + fileSystem: fileSystem, + dependencies: dependencies, + settings: settings, + destinations: config.destinations, + deploymentTargets: config.developmentTarget + ), + ] + var testsDependencies: [TargetDependency] = [.target(name: name)] + var testableTargets: [TestableTarget] = [] + var buildTargets: [TargetReference] = [.target(name)] + + if fileSystem.hasMocks { + targets.append( + Self.makeMocksTarget( + name: name, + pathPrefix: pathPrefix, + settings: settings, + destinations: config.destinations, + deploymentTargets: config.developmentTarget + ) + ) + testsDependencies.append(.target(name: "\(name)Mocks")) + } + + if fileSystem.hasUnitTests { + let testsTargetName = "\(name)Tests" + targets.append( + Self.makeUnitTestsTarget( + name: name, + testsTargetName: testsTargetName, + pathPrefix: pathPrefix, + fileSystem: fileSystem, + testsDependencies: testsDependencies, + testDependencies: testDependencies, + settings: settings, + destinations: config.destinations, + deploymentTargets: config.developmentTarget + ) + ) + testableTargets.append( + .testableTarget( + target: .target(testsTargetName), + parallelization: .swiftTestingOnly + ) + ) + buildTargets.append(.target(testsTargetName)) + } + + if fileSystem.hasSnapshotTests { + let snapshotTestsTargetName = "\(name)SnapshotTests" + targets.append( + Self.makeSnapshotTestsTarget( + name: name, + snapshotTestsTargetName: snapshotTestsTargetName, + pathPrefix: pathPrefix, + fileSystem: fileSystem, + testsDependencies: testsDependencies, + snapshotTestDependencies: snapshotTestDependencies, + settings: settings, + destinations: config.destinations, + deploymentTargets: config.developmentTarget + ) + ) + testableTargets.append( + .testableTarget( + target: .target(snapshotTestsTargetName), + parallelization: .swiftTestingOnly + ) + ) + buildTargets.append(.target(snapshotTestsTargetName)) + } + + self.targets = targets + self.schemes = [Self.makeScheme(name: name, testableTargets: testableTargets, buildTargets: buildTargets)] + self.testableTargets = testableTargets + self.codeCoverageTargets = includeInCoverage ? [.target(name)] : [] + } +} + +// MARK: - Private Helpers + +extension FrameworkModule { + private static func makeFrameworkTarget( + name: String, + pathPrefix: String, + fileSystem: ModuleFileSystem, + dependencies: [ModuleDependency], + settings: Settings, + destinations: ProjectDescription.Destinations, + deploymentTargets: DeploymentTargets + ) -> Target { + let resources: ResourceFileElements? = fileSystem.hasResources ? [ + .glob(pattern: "\(pathPrefix)Sources/Resources/**", excluding: []), + ] : nil + + return .target( + name: name, + destinations: destinations, + product: .framework, + bundleId: "${PRODUCT_BUNDLE_IDENTIFIER}.\(name)", + deploymentTargets: deploymentTargets, + sources: ["\(pathPrefix)Sources/**"], + resources: resources, + scripts: sourceTargetScripts(path: "\(pathPrefix)Sources"), + dependencies: dependencies.map(\.targetDependency), + settings: settings + ) + } + + private static func makeMocksTarget( + name: String, + pathPrefix: String, + settings: Settings, + destinations: ProjectDescription.Destinations, + deploymentTargets: DeploymentTargets + ) -> Target { + .target( + name: "\(name)Mocks", + destinations: destinations, + product: .framework, + bundleId: "${PRODUCT_BUNDLE_IDENTIFIER}.\(name)Mocks", + deploymentTargets: deploymentTargets, + sources: ["\(pathPrefix)Mocks/**"], + dependencies: [.target(name: name)], + settings: settings + ) + } + + private static func makeUnitTestsTarget( + name: String, + testsTargetName: String, + pathPrefix: String, + fileSystem: ModuleFileSystem, + testsDependencies: [TargetDependency], + testDependencies: [ModuleDependency], + settings: Settings, + destinations: ProjectDescription.Destinations, + deploymentTargets: DeploymentTargets + ) -> Target { + let unitSources: SourceFilesList = fileSystem.hasSharedTests + ? ["\(pathPrefix)Tests/Unit/**", "\(pathPrefix)Tests/Shared/**"] + : ["\(pathPrefix)Tests/Unit/**"] + + let unitResources: ResourceFileElements = fileSystem.hasSharedTests ? [ + .glob(pattern: "\(pathPrefix)Tests/Shared/Resources/**", excluding: []), + .glob(pattern: "\(pathPrefix)Tests/Shared/Fixtures/**", excluding: []), + ] : [] + + return .target( + name: testsTargetName, + destinations: destinations, + product: .unitTests, + bundleId: "${PRODUCT_BUNDLE_IDENTIFIER}.\(testsTargetName)", + deploymentTargets: deploymentTargets, + sources: unitSources, + resources: unitResources, + dependencies: testsDependencies + testDependencies.map(\.targetDependency), + settings: settings + ) + } + + private static func makeSnapshotTestsTarget( + name: String, + snapshotTestsTargetName: String, + pathPrefix: String, + fileSystem: ModuleFileSystem, + testsDependencies: [TargetDependency], + snapshotTestDependencies: [ModuleDependency], + settings: Settings, + destinations: ProjectDescription.Destinations, + deploymentTargets: DeploymentTargets + ) -> Target { + let snapshotSources: SourceFilesList = fileSystem.hasSharedTests + ? ["\(pathPrefix)Tests/Snapshots/**", "\(pathPrefix)Tests/Shared/**"] + : ["\(pathPrefix)Tests/Snapshots/**"] + + let snapshotResources: ResourceFileElements = fileSystem.hasSharedTests ? [ + .glob(pattern: "\(pathPrefix)Tests/Shared/Resources/**", excluding: []), + ] : [] + + let snapshotDeps = testsDependencies + + snapshotTestDependencies.map(\.targetDependency) + + return .target( + name: snapshotTestsTargetName, + destinations: destinations, + product: .unitTests, + bundleId: "${PRODUCT_BUNDLE_IDENTIFIER}.\(snapshotTestsTargetName)", + deploymentTargets: deploymentTargets, + sources: snapshotSources, + resources: snapshotResources, + dependencies: snapshotDeps, + settings: settings + ) + } + + private static func makeScheme( + name: String, + testableTargets: [TestableTarget], + buildTargets: [TargetReference] + ) -> Scheme { + if !testableTargets.isEmpty { + return .scheme( + name: name, + buildAction: .buildAction(targets: buildTargets), + testAction: .targets( + testableTargets, + options: .options( + coverage: true, + codeCoverageTargets: [.target(name)] + ) + ) + ) + } else { + return .scheme( + name: name, + buildAction: .buildAction(targets: [.target(name)]) + ) + } + } +} diff --git a/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ModuleAggregation.swift b/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ModuleAggregation.swift new file mode 100644 index 00000000..c2f194ea --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ModuleAggregation.swift @@ -0,0 +1,37 @@ +import ProjectDescription + +/// Unified aggregation logic for module collections. +/// +/// Always uses `.testPlans(...)` with an auto-generated test plan, regardless +/// of the active module strategy. This ensures the `xcodebuild test` command +/// is the same for both `FrameworkModule` and `SPMModule`, so CI never needs +/// to change when switching strategies. +enum ModuleAggregation { + /// Generates a test action for the aggregate Dev scheme. + /// + /// Always generates a test plan via `TestPlanGenerator` and references it + /// as a string path. The test plan is the default in the scheme, so + /// `xcodebuild test -scheme "Challenge (Dev)"` picks it up automatically. + static func aggregateTestAction( + modules: [any ModuleContract], + appTargetReference: TargetReference, + config: ProjectConfig = projectConfig + ) -> TestAction { + let testPlanName = TestPlanGenerator.generate( + appName: config.appName, + modules: modules + ) + return .testPlans([Path(stringLiteral: testPlanName)]) + } + + /// Generates coverage mode for workspace autogenerated schemes. + /// + /// Uses `.relevant` so coverage is driven by the test plan's + /// `codeCoverageTargets`, keeping it consistent across strategies. + static func aggregateCoverageMode( + modules: [any ModuleContract], + appTargetReference: TargetReference + ) -> Workspace.GenerationOptions.AutogeneratedWorkspaceSchemes.CodeCoverageMode { + .relevant + } +} diff --git a/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ModuleContract.swift b/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ModuleContract.swift new file mode 100644 index 00000000..30955172 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ModuleContract.swift @@ -0,0 +1,57 @@ +import ProjectDescription + +/// Defines how a module integrates into the Tuist project. +/// +/// Each concrete implementation represents a different integration strategy: +/// - `FrameworkModule`: Modules as framework targets in the root project. +/// - `SPMModule`: Modules as SPM local packages (each with its own `Package.swift`). +/// +/// **Tuist 4.x limitation:** All modules must use the same strategy — mixing SPM +/// packages with framework targets in the same build is not supported. +/// Use `ModuleStrategy.active` in `ModuleStrategy.swift` to select the active strategy. +/// +/// New strategies (e.g., `XCFrameworkModule`) conform to this protocol without +/// modifying existing code (Open/Closed Principle). +public protocol ModuleContract: Sendable { + // MARK: - Instance Properties + + /// Module directory relative to the workspace root (e.g., `"Features/Character"`, `"AppKit"`). + var directory: String { get } + + /// Resolved target name (e.g., `"ChallengeCharacter"`). + var name: String { get } + + /// Whether the module's source target should be included in code coverage. + var includeInCoverage: Bool { get } + + /// Framework targets generated by the module. Empty for SPM. + var targets: [Target] { get } + + /// Per-module schemes generated by the module. Empty for SPM. + var schemes: [Scheme] { get } + + /// Target dependency for consuming targets. + /// SPM: `.package(product:)` / Framework: `.target(name:)` + var targetDependency: TargetDependency { get } + + /// Mocks dependency for consuming targets. + /// SPM: `.package(product:)` / Framework: `.target(name:)` + var mocksTargetDependency: TargetDependency { get } + + /// Package reference for SPM strategy. Nil for Framework. + var packageReference: Package? { get } + + /// Testable targets for the Dev scheme. Empty for SPM. + var testableTargets: [TestableTarget] { get } + + /// Code coverage target references. Empty for SPM. + var codeCoverageTargets: [TargetReference] { get } + + /// Per-target build settings for `Tuist/Package.swift`. + /// SPM: one entry per detected target (source, mocks, tests). Framework: empty. + var packageTargetSettings: [String: Settings] { get } + + /// Container path for test plan entries. + /// SPM: `"container:{directory}"`. Framework: `"container:"`. + var containerPath: String { get } +} diff --git a/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ModuleDependency.swift b/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ModuleDependency.swift new file mode 100644 index 00000000..e095c37c --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ModuleDependency.swift @@ -0,0 +1,32 @@ +import ProjectDescription + +/// A dependency reference for module configuration. +/// +/// Used in module init parameters to declare dependencies on other modules +/// or external SPM packages. +public enum ModuleDependency { + /// Dependency on a module's main source target. + case module(any ModuleContract) + + /// Dependency on a module's mocks target. + case moduleMocks(any ModuleContract) + + /// Dependency on an external SPM package product. + case external(ExternalPackage) +} + +// MARK: - Target Resolution + +extension ModuleDependency { + /// Resolves the dependency to a `TargetDependency`. + var targetDependency: TargetDependency { + switch self { + case let .module(module): + module.targetDependency + case let .moduleMocks(module): + module.mocksTargetDependency + case let .external(package): + .external(name: package.productName) + } + } +} diff --git a/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ModuleFactory.swift b/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ModuleFactory.swift new file mode 100644 index 00000000..aba74eda --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ModuleFactory.swift @@ -0,0 +1,38 @@ +import ProjectDescription + +/// Creates modules using the active ``ModuleStrategy``. +public enum ModuleFactory { + /// Creates a module using the active strategy. + public static func create( + directory: String, + dependencies: [ModuleDependency] = [], + testDependencies: [ModuleDependency] = [], + snapshotTestDependencies: [ModuleDependency] = [], + includeInCoverage: Bool = true, + settingsOverrides: SettingsDictionary = [:], + config: ProjectConfig = projectConfig + ) -> any ModuleContract { + switch ModuleStrategy.active { + case .spm: + SPMModule( + directory: directory, + dependencies: dependencies, + testDependencies: testDependencies, + snapshotTestDependencies: snapshotTestDependencies, + includeInCoverage: includeInCoverage, + settingsOverrides: settingsOverrides, + config: config + ) + case .framework: + FrameworkModule( + directory: directory, + dependencies: dependencies, + testDependencies: testDependencies, + snapshotTestDependencies: snapshotTestDependencies, + includeInCoverage: includeInCoverage, + settingsOverrides: settingsOverrides, + config: config + ) + } + } +} diff --git a/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ModuleFileSystem.swift b/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ModuleFileSystem.swift new file mode 100644 index 00000000..d33b1cdb --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ModuleFileSystem.swift @@ -0,0 +1,120 @@ +import Foundation + +/// Filesystem introspection utilities for module directories. +/// +/// Detects the presence of source folders (Mocks, Tests, Resources) and derives +/// the target name from the directory path. Separated from `ModuleDefinition` +/// to respect Single Responsibility: `ModuleDefinition` captures configuration, +/// `ModuleFileSystem` handles filesystem queries. +struct ModuleFileSystem { + private let directory: String + private let appName: String + + init(directory: String, appName: String) { + self.directory = directory + self.appName = appName + } + + // MARK: - Derived Metadata + + /// Derives the Tuist target name from the module directory path. + /// + /// Example: `"Features/Character"` -> `"ChallengeCharacter"` + var targetName: String { + let components = directory.split(separator: "/") + guard let last = components.last else { + fatalError("Module directory must not be empty") + } + return "\(self.appName)\(last)" + } + + /// Whether the module has a Mocks folder with Swift files. + var hasMocks: Bool { + folderContainsFiles(at: "Mocks", withExtension: ".swift") + } + + /// Whether the module has a Tests/Unit folder with Swift files. + var hasUnitTests: Bool { + folderContainsFiles(at: "Tests/Unit", withExtension: ".swift") + } + + /// Whether the module has a Tests/Snapshots folder with Swift files. + var hasSnapshotTests: Bool { + folderContainsFiles(at: "Tests/Snapshots", withExtension: ".swift") + } + + /// Whether the module has a Tests/Shared folder with Swift files. + var hasSharedTests: Bool { + folderContainsFiles(at: "Tests/Shared", withExtension: ".swift") + } + + /// Whether the module has a Sources/Resources folder with any files. + var hasResources: Bool { + folderContainsFiles(at: "Sources/Resources") + } + + /// Whether the module has a Tests/Shared/Fixtures folder with any files. + var hasSharedFixtures: Bool { + folderContainsFiles(at: "Tests/Shared/Fixtures") + } + + /// Whether the module has a Tests/Shared/Resources folder with any files. + var hasSharedResources: Bool { + folderContainsFiles(at: "Tests/Shared/Resources") + } + + /// All `__Snapshots__` directory paths relative to `Tests/`. + /// + /// Example: `["Snapshots/Presentation/CharacterDetail/__Snapshots__"]`. + var snapshotExclusions: [String] { + let testsPath = "\(workspaceRoot)/\(directory)/Tests" + let fileManager = FileManager.default + var exclusions: [String] = [] + + guard let enumerator = fileManager.enumerator(atPath: testsPath) else { + return [] + } + + while let path = enumerator.nextObject() as? String { + if path.hasSuffix("__Snapshots__") { + var isDirectory: ObjCBool = false + let fullPath = "\(testsPath)/\(path)" + if fileManager.fileExists(atPath: fullPath, isDirectory: &isDirectory), + isDirectory.boolValue + { + exclusions.append(path) + } + } + } + + return exclusions.sorted() + } + + // MARK: - Private + + /// Checks if a subfolder (relative to the module directory) contains any files. + private func folderContainsFiles(at subfolder: String, withExtension ext: String? = nil) -> Bool { + let path = "\(workspaceRoot)/\(directory)/\(subfolder)" + let fileManager = FileManager.default + + guard let enumerator = fileManager.enumerator(atPath: path) else { + return false + } + + while let file = enumerator.nextObject() as? String { + if let ext { + if file.hasSuffix(ext) { + return true + } + } else { + var isDirectory: ObjCBool = false + let fullPath = "\(path)/\(file)" + if fileManager.fileExists(atPath: fullPath, isDirectory: &isDirectory), !isDirectory.boolValue { + return true + } + } + } + + return false + } +} diff --git a/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ModuleStrategy.swift b/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ModuleStrategy.swift new file mode 100644 index 00000000..7f16de34 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/ModuleStrategy.swift @@ -0,0 +1,29 @@ +import ProjectDescription + +/// Available module integration strategies. +/// +/// Tuist 4.x does not support mixing SPM local packages and framework targets +/// in the same build. All modules must use the same strategy. +/// +/// - `.spm`: Modules as SPM local packages (each with its own `Package.swift`). +/// - `.framework`: Modules as framework targets in the root project. +public enum ModuleStrategy: String { + case spm + case framework + + /// The active strategy for the entire project. + /// + /// Resolved from the `TUIST_MODULE_STRATEGY` environment variable at generation time. + /// Falls back to `.framework` when the variable is not set. + /// + /// Usage: + /// ```bash + /// TUIST_MODULE_STRATEGY=spm ./generate.sh + /// ``` + public static let active: ModuleStrategy = { + if case let .string(value) = ProjectDescription.Environment.moduleStrategy { + return ModuleStrategy(rawValue: value) ?? .framework + } + return .framework + }() +} diff --git a/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/SPMModule.swift b/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/SPMModule.swift new file mode 100644 index 00000000..51dedd00 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/ModuleKit/Strategy/SPMModule.swift @@ -0,0 +1,112 @@ +import ProjectDescription + +/// Module implementation that integrates as an SPM local package. +/// +/// Each module is an independent SPM package with its own `Package.swift`. +/// The root project references them via `packages:` and resolves dependencies through SPM. +/// Tests run via an auto-generated `.xctestplan` file. +public struct SPMModule: ModuleContract, @unchecked Sendable { + public let directory: String + public let name: String + public let includeInCoverage: Bool + public let packageTargetSettings: [String: Settings] + + // MARK: - SPM: Computed (always empty) + + public var targets: [Target] { [] } + public var schemes: [Scheme] { [] } + public var testableTargets: [TestableTarget] { [] } + public var codeCoverageTargets: [TargetReference] { [] } + + // MARK: - SPM: Computed (derived from name) + + public var targetDependency: TargetDependency { .package(product: name) } + public var mocksTargetDependency: TargetDependency { .package(product: "\(name)Mocks") } + public var packageReference: Package? { .package(path: Path(stringLiteral: directory)) } + public var containerPath: String { "container:\(directory)" } + + // MARK: - Init + + public init( + directory: String, + dependencies: [ModuleDependency] = [], + testDependencies: [ModuleDependency] = [], + snapshotTestDependencies: [ModuleDependency] = [], + includeInCoverage: Bool = true, + settingsOverrides: SettingsDictionary = [:], + config: ProjectConfig = projectConfig + ) { + Self.validateDependencies( + directory: directory, + dependencies: dependencies, + testDependencies: testDependencies, + snapshotTestDependencies: snapshotTestDependencies + ) + + let fileSystem = ModuleFileSystem(directory: directory, appName: config.appName) + let name = fileSystem.targetName + self.directory = directory + self.name = name + self.includeInCoverage = includeInCoverage + + let settings: Settings = .settings( + base: config.baseSettings.merging(settingsOverrides) { _, new in new } + ) + + var targetSettings: [String: Settings] = [name: settings] + + if fileSystem.hasMocks { + targetSettings["\(name)Mocks"] = settings + } + + if fileSystem.hasUnitTests || fileSystem.hasSnapshotTests { + targetSettings["\(name)Tests"] = settings + } + + self.packageTargetSettings = targetSettings + + PackageSwiftGenerator.generate( + directory: directory, + name: name, + dependencies: dependencies, + testDependencies: testDependencies, + snapshotTestDependencies: snapshotTestDependencies, + settingsOverrides: settingsOverrides, + fileSystem: fileSystem, + config: config + ) + } +} + +// MARK: - Validation + +extension SPMModule { + /// Validates that no dependency points to a framework module. + /// + /// SPM packages can only depend on other SPM packages or external dependencies. + /// A `nil` `packageReference` indicates a framework module. + private static func validateDependencies( + directory: String, + dependencies: [ModuleDependency], + testDependencies: [ModuleDependency], + snapshotTestDependencies: [ModuleDependency] + ) { + let allDeps = dependencies + testDependencies + snapshotTestDependencies + for dep in allDeps { + switch dep { + case let .module(module) where module.packageReference == nil: + fatalError( + "SPM module '\(directory)' cannot depend on framework module '\(module.directory)'. " + + "SPM packages can only depend on other SPM packages or external dependencies." + ) + case let .moduleMocks(module) where module.packageReference == nil: + fatalError( + "SPM module '\(directory)' cannot depend on framework module mocks '\(module.directory)'. " + + "SPM packages can only depend on other SPM packages or external dependencies." + ) + default: + break + } + } + } +} diff --git a/Tuist/ProjectDescriptionHelpers/ModuleKit/WorkspaceRoot.swift b/Tuist/ProjectDescriptionHelpers/ModuleKit/WorkspaceRoot.swift new file mode 100644 index 00000000..4182872b --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/ModuleKit/WorkspaceRoot.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Absolute path to the workspace root directory. +/// +/// Walks up from `#file` until the `Tuist` directory is found, +/// then returns the parent (workspace root). This approach is robust +/// regardless of file depth within `Tuist/ProjectDescriptionHelpers/`. +let workspaceRoot: String = { + var url = URL(fileURLWithPath: #file) + while url.lastPathComponent != "Tuist", url.path != "/" { + url = url.deletingLastPathComponent() + } + return url.deletingLastPathComponent().path +}() diff --git a/Tuist/ProjectDescriptionHelpers/Modules.swift b/Tuist/ProjectDescriptionHelpers/Modules.swift deleted file mode 100644 index bba884f7..00000000 --- a/Tuist/ProjectDescriptionHelpers/Modules.swift +++ /dev/null @@ -1,23 +0,0 @@ -import ProjectDescription - -/// Central registry of all modules in the project. -public enum Modules { - /// All modules in the project. - static let all: [Module] = [ - coreModule, - networkingModule, - snapshotTestKitModule, - designSystemModule, - resourcesModule, - characterModule, - episodeModule, - homeModule, - systemModule, - appKitModule, - ] - - /// All package references for Project.swift packages array. - static var packageReferences: [Package] { - all.map(\.packageReference) - } -} diff --git a/Tuist/ProjectDescriptionHelpers/Modules/AppKitModule.swift b/Tuist/ProjectDescriptionHelpers/Modules/AppKitModule.swift index a3bf9257..5053cf9a 100644 --- a/Tuist/ProjectDescriptionHelpers/Modules/AppKitModule.swift +++ b/Tuist/ProjectDescriptionHelpers/Modules/AppKitModule.swift @@ -1,3 +1,20 @@ -import ProjectDescription - -public let appKitModule = Module.create(directory: "AppKit") +public let appKitModule = ModuleFactory.create( + directory: "AppKit", + dependencies: [ + .module(coreModule), + .module(homeModule), + .module(characterModule), + .module(episodeModule), + .module(systemModule), + .module(networkingModule), + ], + testDependencies: [ + .moduleMocks(coreModule), + .moduleMocks(networkingModule), + ], + snapshotTestDependencies: [ + .module(snapshotTestKitModule), + .moduleMocks(coreModule), + .moduleMocks(networkingModule), + ] +) diff --git a/Tuist/ProjectDescriptionHelpers/Modules/CharacterModule.swift b/Tuist/ProjectDescriptionHelpers/Modules/CharacterModule.swift index e3c1f59c..03ca0783 100644 --- a/Tuist/ProjectDescriptionHelpers/Modules/CharacterModule.swift +++ b/Tuist/ProjectDescriptionHelpers/Modules/CharacterModule.swift @@ -1,3 +1,18 @@ -import ProjectDescription - -public let characterModule = Module.create(directory: "Features/Character") +public let characterModule = ModuleFactory.create( + directory: "Features/Character", + dependencies: [ + .module(coreModule), + .module(networkingModule), + .module(resourcesModule), + .module(designSystemModule), + ], + testDependencies: [ + .moduleMocks(coreModule), + .moduleMocks(networkingModule), + ], + snapshotTestDependencies: [ + .module(snapshotTestKitModule), + .moduleMocks(coreModule), + .moduleMocks(networkingModule), + ] +) diff --git a/Tuist/ProjectDescriptionHelpers/Modules/CoreModule.swift b/Tuist/ProjectDescriptionHelpers/Modules/CoreModule.swift index bc2a527b..b7a214e8 100644 --- a/Tuist/ProjectDescriptionHelpers/Modules/CoreModule.swift +++ b/Tuist/ProjectDescriptionHelpers/Modules/CoreModule.swift @@ -1,3 +1 @@ -import ProjectDescription - -public let coreModule = Module.create(directory: "Libraries/Core") +public let coreModule = ModuleFactory.create(directory: "Libraries/Core") diff --git a/Tuist/ProjectDescriptionHelpers/Modules/DesignSystemModule.swift b/Tuist/ProjectDescriptionHelpers/Modules/DesignSystemModule.swift index 882beace..85db054f 100644 --- a/Tuist/ProjectDescriptionHelpers/Modules/DesignSystemModule.swift +++ b/Tuist/ProjectDescriptionHelpers/Modules/DesignSystemModule.swift @@ -1,3 +1,13 @@ -import ProjectDescription - -public let designSystemModule = Module.create(directory: "Libraries/DesignSystem") +public let designSystemModule = ModuleFactory.create( + directory: "Libraries/DesignSystem", + dependencies: [ + .module(coreModule), + ], + testDependencies: [ + .moduleMocks(coreModule), + ], + snapshotTestDependencies: [ + .module(snapshotTestKitModule), + .moduleMocks(coreModule), + ] +) diff --git a/Tuist/ProjectDescriptionHelpers/Modules/EpisodeModule.swift b/Tuist/ProjectDescriptionHelpers/Modules/EpisodeModule.swift index 1f041635..b6bcb76c 100644 --- a/Tuist/ProjectDescriptionHelpers/Modules/EpisodeModule.swift +++ b/Tuist/ProjectDescriptionHelpers/Modules/EpisodeModule.swift @@ -1,3 +1,18 @@ -import ProjectDescription - -public let episodeModule = Module.create(directory: "Features/Episode") +public let episodeModule = ModuleFactory.create( + directory: "Features/Episode", + dependencies: [ + .module(coreModule), + .module(designSystemModule), + .module(networkingModule), + .module(resourcesModule), + ], + testDependencies: [ + .moduleMocks(coreModule), + .moduleMocks(networkingModule), + ], + snapshotTestDependencies: [ + .module(snapshotTestKitModule), + .moduleMocks(coreModule), + .moduleMocks(networkingModule), + ] +) diff --git a/Tuist/ProjectDescriptionHelpers/Modules/HomeModule.swift b/Tuist/ProjectDescriptionHelpers/Modules/HomeModule.swift index ef7afe61..81b05e02 100644 --- a/Tuist/ProjectDescriptionHelpers/Modules/HomeModule.swift +++ b/Tuist/ProjectDescriptionHelpers/Modules/HomeModule.swift @@ -1,3 +1,16 @@ -import ProjectDescription - -public let homeModule = Module.create(directory: "Features/Home") +public let homeModule = ModuleFactory.create( + directory: "Features/Home", + dependencies: [ + .module(coreModule), + .module(designSystemModule), + .module(resourcesModule), + .external(lottiePackage), + ], + testDependencies: [ + .moduleMocks(coreModule), + ], + snapshotTestDependencies: [ + .module(snapshotTestKitModule), + .moduleMocks(coreModule), + ] +) diff --git a/Tuist/ProjectDescriptionHelpers/Modules/NetworkingModule.swift b/Tuist/ProjectDescriptionHelpers/Modules/NetworkingModule.swift index b96e4bd3..1176ec6c 100644 --- a/Tuist/ProjectDescriptionHelpers/Modules/NetworkingModule.swift +++ b/Tuist/ProjectDescriptionHelpers/Modules/NetworkingModule.swift @@ -1,3 +1,14 @@ import ProjectDescription -public let networkingModule = Module.create(directory: "Libraries/Networking") +public let networkingModule = ModuleFactory.create( + directory: "Libraries/Networking", + dependencies: [ + .module(coreModule), + ], + testDependencies: [ + .moduleMocks(coreModule), + ], + settingsOverrides: [ + "SWIFT_DEFAULT_ACTOR_ISOLATION": .string("nonisolated"), + ] +) diff --git a/Tuist/ProjectDescriptionHelpers/Modules/ResourcesModule.swift b/Tuist/ProjectDescriptionHelpers/Modules/ResourcesModule.swift index 6e51a10f..55438486 100644 --- a/Tuist/ProjectDescriptionHelpers/Modules/ResourcesModule.swift +++ b/Tuist/ProjectDescriptionHelpers/Modules/ResourcesModule.swift @@ -1,6 +1,7 @@ -import ProjectDescription - -public let resourcesModule = Module.create( +public let resourcesModule = ModuleFactory.create( directory: "Shared/Resources", + dependencies: [ + .module(coreModule), + ], includeInCoverage: false ) diff --git a/Tuist/ProjectDescriptionHelpers/Modules/SnapshotTestKitModule.swift b/Tuist/ProjectDescriptionHelpers/Modules/SnapshotTestKitModule.swift index b8718937..913cc89a 100644 --- a/Tuist/ProjectDescriptionHelpers/Modules/SnapshotTestKitModule.swift +++ b/Tuist/ProjectDescriptionHelpers/Modules/SnapshotTestKitModule.swift @@ -1,6 +1,12 @@ import ProjectDescription -public let snapshotTestKitModule = Module.create( +public let snapshotTestKitModule = ModuleFactory.create( directory: "Libraries/SnapshotTestKit", - includeInCoverage: false + dependencies: [ + .external(snapshotTestingPackage), + ], + includeInCoverage: false, + settingsOverrides: [ + "ENABLE_TESTING_SEARCH_PATHS": "YES", + ] ) diff --git a/Tuist/ProjectDescriptionHelpers/Modules/SystemModule.swift b/Tuist/ProjectDescriptionHelpers/Modules/SystemModule.swift index d1779cd0..d25865c2 100644 --- a/Tuist/ProjectDescriptionHelpers/Modules/SystemModule.swift +++ b/Tuist/ProjectDescriptionHelpers/Modules/SystemModule.swift @@ -1,3 +1,15 @@ -import ProjectDescription - -public let systemModule = Module.create(directory: "Features/System") +public let systemModule = ModuleFactory.create( + directory: "Features/System", + dependencies: [ + .module(coreModule), + .module(resourcesModule), + .module(designSystemModule), + ], + testDependencies: [ + .moduleMocks(coreModule), + ], + snapshotTestDependencies: [ + .module(snapshotTestKitModule), + .moduleMocks(coreModule), + ] +) diff --git a/Tuist/ProjectDescriptionHelpers/SwiftLint.swift b/Tuist/ProjectDescriptionHelpers/SwiftLint.swift deleted file mode 100644 index b1b8666e..00000000 --- a/Tuist/ProjectDescriptionHelpers/SwiftLint.swift +++ /dev/null @@ -1,18 +0,0 @@ -import ProjectDescription - -enum SwiftLint { - /// Returns a build script that runs SwiftLint. - /// - Parameters: - /// - path: Optional path to lint. If nil, lints the entire project. - /// - workspaceRoot: Relative path from `$SRCROOT` to the workspace root. Defaults to `"."` (project at root). - static func script(path: String? = nil, workspaceRoot: String = ".") -> TargetScript { - let lintPath = path ?? "." - return .post( - script: """ - "${SRCROOT}/\(workspaceRoot)/Scripts/run_swiftlint.sh" "\(lintPath)" - """, - name: "SwiftLint", - basedOnDependencyAnalysis: false - ) - } -} diff --git a/Tuist/ProjectDescriptionHelpers/TargetScript+BuildPhases.swift b/Tuist/ProjectDescriptionHelpers/TargetScript+BuildPhases.swift new file mode 100644 index 00000000..6667bc89 --- /dev/null +++ b/Tuist/ProjectDescriptionHelpers/TargetScript+BuildPhases.swift @@ -0,0 +1,21 @@ +import ProjectDescription + +extension TargetScript { + /// SwiftLint post-build script. + static func swiftLint(path: String) -> TargetScript { + .post( + script: """ + "${SRCROOT}/Scripts/run_swiftlint.sh" "\(path)" + """, + name: "SwiftLint", + basedOnDependencyAnalysis: false + ) + } +} + +/// Build phase scripts applied to all source targets (app and framework modules). +/// +/// Add new project-wide build phases here — they will apply to every source target. +func sourceTargetScripts(path: String) -> [TargetScript] { + [.swiftLint(path: path)] +} diff --git a/Workspace.swift b/Workspace.swift index a4cb26ae..fbfe2ea4 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -2,11 +2,11 @@ import ProjectDescription import ProjectDescriptionHelpers let workspace = Workspace( - name: appName, + name: mainApp.name, projects: ["."], generationOptions: .options( autogeneratedWorkspaceSchemes: .enabled( - codeCoverageMode: .relevant, + codeCoverageMode: mainApp.coverageMode, testingOptions: [] ) ) diff --git a/docs/Scripts.md b/docs/Scripts.md index f5c8fb60..321b6775 100644 --- a/docs/Scripts.md +++ b/docs/Scripts.md @@ -5,8 +5,9 @@ | Script | Description | |--------|-------------| | `./setup.sh` | Initial setup - installs brew, mise, and project tools | -| `./generate.sh` | Install dependencies and generate the Xcode project | +| `./generate.sh` | Install dependencies and generate the Xcode project (framework strategy) | | `./generate.sh --clean` | Clean Tuist cache, then install dependencies and generate | +| `./generate.sh --strategy spm` | Generate using the SPM module strategy | | `./reset-simulators.sh` | Full simulator reset - fixes corrupted simulator state | | `Scripts/run_swiftlint.sh` | Runs SwiftLint on the codebase (Xcode build phase) | @@ -35,6 +36,21 @@ Generate the Xcode project and install dependencies: ./generate.sh ``` +### Module Strategy + +Switch the module integration strategy at generation time: + +```bash +./generate.sh --strategy framework # Framework targets (default) +./generate.sh --strategy spm # SPM local packages +``` + +Options can be combined: + +```bash +./generate.sh --clean --strategy framework +``` + ### Clean Build To perform a clean build from scratch: @@ -45,6 +61,7 @@ To perform a clean build from scratch: This clears the Tuist cache before generating, useful when: - Switching branches with different dependencies +- Switching module strategies - Resolving cached state issues - Starting fresh after major changes diff --git a/docs/Tuist.md b/docs/Tuist.md index 5ceb6d28..8c19af76 100644 --- a/docs/Tuist.md +++ b/docs/Tuist.md @@ -7,15 +7,30 @@ The project uses [Tuist](https://tuist.io/) for Xcode project generation and dep ``` Tuist/ ├── ProjectDescriptionHelpers/ -│ ├── Config.swift # App name, Swift version, deployment target, workspaceRoot -│ ├── Modules.swift # Central module registry (array + derived properties) -│ ├── App.swift # App project, targets, references, dependencies, coverage -│ ├── Module.swift # Module definition and factory -│ ├── BuildConfiguration.swift # Debug/Release configurations -│ ├── Environment.swift # Environment-specific settings (Dev/Staging/Prod) -│ ├── AppScheme.swift # App scheme generation (per-environment + UI tests) -│ ├── SwiftLint.swift # SwiftLint build phase integration -│ └── Modules/ # Individual module definitions +│ ├── Config.swift # App name, Swift version, deployment target +│ ├── MainApp.swift # Global mainApp instance +│ ├── Environment.swift # Environment-specific settings (Dev/Staging/Prod) +│ ├── ExternalPackages.swift # External SPM package definitions +│ ├── TargetScript+BuildPhases.swift # Build phase scripts (SwiftLint, etc.) +│ ├── ModuleKit/ # Core module infrastructure +│ │ ├── ProjectConfig.swift # Shared project configuration +│ │ ├── BuildConfiguration.swift # Debug/Release configurations +│ │ ├── WorkspaceRoot.swift # Workspace root path helpers +│ │ ├── App.swift # App project, targets, schemes, coverage +│ │ ├── Strategy/ # Module integration strategies +│ │ │ ├── ModuleStrategy.swift # Available strategies and active selection +│ │ │ ├── ModuleFactory.swift # Factory method to create modules +│ │ │ ├── ModuleContract.swift # Protocol defining module behavior +│ │ │ ├── FrameworkModule.swift # Framework target strategy +│ │ │ ├── SPMModule.swift # SPM local package strategy +│ │ │ ├── ModuleDependency.swift # Dependency specification types +│ │ │ ├── ModuleFileSystem.swift # File system detection for modules +│ │ │ ├── ModuleAggregation.swift # Aggregates test actions and coverage +│ │ │ └── ExternalPackage.swift # External package wrapper +│ │ └── Generators/ # Manifest-time code generation +│ │ ├── PackageSwiftGenerator.swift # Auto-generates Package.swift for SPM modules +│ │ └── TestPlanGenerator.swift # Auto-generates test plans for SPM modules +│ └── Modules/ # Individual module instantiations │ ├── CoreModule.swift │ ├── NetworkingModule.swift │ ├── ResourcesModule.swift @@ -26,68 +41,182 @@ Tuist/ │ ├── SystemModule.swift │ ├── SnapshotTestKitModule.swift │ └── AppKitModule.swift -└── Package.swift # SPM dependencies +└── Package.swift # External SPM dependencies + PackageSettings ``` ## Key Settings ```swift -// Config.swift +// ProjectConfig.swift appName = "Challenge" -swiftVersion = "6.2" -developmentTarget = .iOS("17.0") +swiftToolsVersion = "6.2" +iosMajorVersion = "17" destinations = [.iPhone, .iPad] ``` -## Modules +## Module Strategy Pattern -All modules are **SPM local packages** with their own `Package.swift`. Each package defines `.library()` products (source + mocks) and a single `.testTarget` combining all test sources. The root project references them via `packages: Modules.packageReferences`. +The project uses a **Strategy + Factory Method** pattern for module integration. `ModuleStrategy` defines available strategies and `ModuleFactory` creates modules using the active one: -Each module is a global `Module` constant with the following properties: +```swift +// ModuleStrategy.swift — resolves from TUIST_MODULE_STRATEGY env var (default: framework) +public enum ModuleStrategy: String, CaseIterable { + case spm + case framework + + public static let active: ModuleStrategy = { /* read from Environment.moduleStrategy */ }() +} + +// ModuleFactory.swift — creates modules using the active strategy +public enum ModuleFactory { + public static func create(directory:dependencies:...) -> any ModuleContract { ... } +} +``` -| Property | Type | Purpose | -|----------|------|---------| -| `targetDependency` | `TargetDependency` | `.package(product: name)` for consuming targets | -| `mocksTargetDependency` | `TargetDependency` | `.package(product: "\(name)Mocks")` for mock dependencies | -| `packageReference` | `Package` | `.package(path: directory)` for the project packages array | +Switch strategy at generation time: -## App, Modules, and AppScheme +```bash +./generate.sh # default: framework +./generate.sh --strategy spm # switch to spm +``` -Three enums with clear responsibilities and a unidirectional dependency chain: +| Strategy | Description | +|----------|-------------| +| `framework` | Modules as framework targets in the root project (default) | +| `spm` | Modules as SPM local packages with auto-generated `Package.swift` | +**Tuist 4.x limitation:** All modules must use the same strategy — mixing is not supported. + +Each module is a global constant created via `ModuleFactory.create(...)`: + +```swift +// Tuist/ProjectDescriptionHelpers/Modules/CharacterModule.swift +public let characterModule = ModuleFactory.create( + directory: "Features/Character", + dependencies: [ + .module(coreModule), + .module(networkingModule), + .module(resourcesModule), + .module(designSystemModule), + ], + testDependencies: [ + .moduleMocks(coreModule), + .moduleMocks(networkingModule), + ], + snapshotTestDependencies: [ + .module(snapshotTestKitModule), + .moduleMocks(coreModule), + .moduleMocks(networkingModule), + ] +) ``` -AppScheme → App → Modules + +### Module Dependencies + +Modules declare dependencies using the `ModuleDependency` enum: + +| Case | Usage | Description | +|------|-------|-------------| +| `.module(someModule)` | `dependencies`, `snapshotTestDependencies` | Source target of another module | +| `.moduleMocks(someModule)` | `testDependencies`, `snapshotTestDependencies` | Mocks target of another module | +| `.external(somePackage)` | `dependencies` | External SPM package | + +### FrameworkModule Strategy (Current) + +Each `FrameworkModule` generates the following targets in the root project: + +1. **Source** (e.g., `ChallengeCharacter`) — framework with production code +2. **Mocks** (e.g., `ChallengeCharacterMocks`) — framework with public mocks (if `Mocks/` exists) +3. **Unit Tests** (e.g., `ChallengeCharacterTests`) — unit test bundle (if `Tests/Unit/` exists) +4. **Snapshot Tests** (e.g., `ChallengeCharacterSnapshotTests`) — unit test bundle (if `Tests/Snapshots/` exists) + +### SPMModule Strategy (Alternative) + +Each `SPMModule` auto-generates a `Package.swift` (via `PackageSwiftGenerator`) with: +- `.library()` products (source + mocks) +- A single `.testTarget` merging unit and snapshot tests +- Dependencies resolved via relative `path:` references + +## External Packages + +All external SPM dependencies are centralized in `ExternalPackages.swift`: + +| Package | Product | Version | Product Type | +|---------|---------|---------|--------------| +| swift-snapshot-testing | SnapshotTesting | 1.17.0 | .framework | +| lottie-ios | Lottie | 4.6.0 | .framework | +| SwiftMockServer | SwiftMockServerBinary | 1.1.1 | .framework | + +`Tuist/Package.swift` uses `#if TUIST` to derive dependencies and settings from `allExternalPackages`. The `#else` branch has a hardcoded fallback for `tuist install` (pure SPM mode). + +### Adding an External Package + +1. Define in `ExternalPackages.swift`: + +```swift +public let newPackage = ExternalPackage( + productName: "PackageName", + url: "https://github.com/owner/repo", + version: "1.0.0", + productType: .framework +) ``` -| Enum | File | Responsibility | -|------|------|----------------| -| `Modules` | `Modules.swift` | Central module registry (`all` array) with derived `packageReferences` | -| `App` | `App.swift` | App project definition (targets, packages, schemes), references (`targetReference`, `uiTestsTargetReference`) | -| `AppScheme` | `AppScheme.swift` | Scheme factory — creates environment, UI test, and module test schemes | +2. Add to `allExternalPackages` array +3. Add to `Package.swift` `#else` fallback block (keep in sync) +4. Use in module: `.external(newPackage)` -The root `Project.swift` is: `let project = App.project`. It includes the app target, UI tests target, and all module packages. +## App and Project -### Adding a New Module +The root `Project.swift` delegates to `mainApp.project`: -**3 steps** needed: +```swift +let project = mainApp.project +``` + +`App.swift` aggregates all module data into a single `Project`: +- **Targets**: app + UI tests + all module framework targets +- **Schemes**: per-environment (Dev/Staging/Prod) + UI tests + per-module schemes +- **Packages**: aggregated from modules (empty for FrameworkModule, populated for SPMModule) +- **Test action**: `ModuleAggregation` always uses `.testPlans(...)` with an auto-generated test plan +- **Coverage**: `ModuleAggregation` always uses `.relevant` (test plan drives coverage) + +The `mainApp` instance is defined in `MainApp.swift` with all 10 modules and `appKitModule` as entry point. + +### Adding a New Module -1. Create the module's `Package.swift` (source, mocks, tests targets with proper dependencies) -2. Create module definition in `Tuist/ProjectDescriptionHelpers/Modules/{Feature}Module.swift` -3. Add the module to `Modules.all` in `Modules.swift` +1. Create the module directory with `Sources/` (and optionally `Mocks/`, `Tests/Unit/`, `Tests/Snapshots/`) +2. Create `Tuist/ProjectDescriptionHelpers/Modules/{Name}Module.swift` +3. Add the module to the `modules` array in `MainApp.swift` -Additionally, add the module's test target to `Challenge.xctestplan` and its target settings to `Tuist/Package.swift`. +`ModuleFileSystem` auto-detects folder structure — no manual target configuration needed. ## Swift 6 Concurrency The project uses strict Swift 6 concurrency with MainActor isolation by default: ```swift -// Config.swift — projectBaseSettings (shared across all targets) +// ProjectConfig.swift — baseSettings (shared across all targets) "SWIFT_VERSION": "6.2" "SWIFT_APPROACHABLE_CONCURRENCY": "YES" "SWIFT_DEFAULT_ACTOR_ISOLATION": "MainActor" ``` +### Per-Module Overrides + +Modules can override settings via `settingsOverrides`: + +```swift +// NetworkingModule.swift — nonisolated default for data layer types +public let networkingModule = Module( + directory: "Libraries/Networking", + dependencies: [.module(coreModule)], + settingsOverrides: [ + "SWIFT_DEFAULT_ACTOR_ISOLATION": .string("nonisolated"), + ] +) +``` + ## Build Configurations | Configuration | Type | Compilation Conditions | @@ -205,63 +334,23 @@ Libraries ──► Features ❌ Forbidden This ensures features remain independent and can be developed, tested, and potentially extracted as separate modules. -## Module Definition - -Each module has its own `Package.swift` that defines source, mocks, and test targets. The Tuist `Module.create()` factory creates a metadata holder that auto-detects the Mocks folder: - -### Example Module Definition (Tuist) - -```swift -// Tuist/ProjectDescriptionHelpers/Modules/CharacterModule.swift -public let characterModule = Module.create(directory: "Features/Character") -``` - -### Example Package.swift (SPM) +## Auto-Generation System -```swift -// Features/Character/Package.swift -let package = Package( - name: "ChallengeCharacter", - platforms: [.iOS(.v17)], - products: [ - .library(name: "ChallengeCharacter", targets: ["ChallengeCharacter"]), - ], - dependencies: [ - .package(path: "../../Libraries/Core"), - .package(path: "../../Libraries/Networking"), - // ... - ], - targets: [ - .target(name: "ChallengeCharacter", dependencies: [...], path: "Sources"), - .testTarget(name: "ChallengeCharacterTests", dependencies: [...], path: "Tests"), - ] -) -``` +The project includes manifest-time generators that support both module strategies: -Cross-module dependencies use relative `path:` references. SPM package identity uses the **directory name** (last path component), not the `name:` field. +### PackageSwiftGenerator -### Build Settings +Called from `SPMModule.init()` during manifest evaluation. Auto-creates `Package.swift` for each SPM module with proper targets, dependencies, and Swift settings (MainActor or nonisolated isolation). -Per-target build settings are configured in `Tuist/Package.swift` via `targetSettings`: +### TestPlanGenerator -```swift -// Tuist/Package.swift -targetSettings: [ - "ChallengeCore": .settings(base: projectBaseSettings), - "ChallengeNetworking": .settings(base: projectBaseSettings.merging([ - "SWIFT_DEFAULT_ACTOR_ISOLATION": .string("nonisolated"), - ]) { _, new in new }), - "ChallengeSnapshotTestKit": .settings(base: projectBaseSettings.merging([ - "ENABLE_TESTING_SEARCH_PATHS": "YES", - ]) { _, new in new }), -] -``` +Called from `ModuleAggregation.aggregateTestAction()` regardless of the active strategy. Creates an `.xctestplan` file aggregating all module test targets with code coverage configuration. -The Networking module overrides the project-wide `MainActor` default to `nonisolated` because all Networking types are pure data structures or stateless services with no UI concerns. +Both generators execute at manifest-time (`tuist generate`), not build-time. ## Testing -Module unit+snapshot tests use `xcodebuild test` with the `Challenge (Dev)` scheme and `Challenge` test plan, because `tuist test` doesn't support `.testPlans()` with SPM packages. UI tests use `tuist test "ChallengeUITests"`. +Module tests use `xcodebuild test` with the `Challenge (Dev)` scheme. `ModuleAggregation` always generates a `.xctestplan` file that aggregates all module test targets, regardless of the active strategy. The test command is always the same: ```bash # Run all module tests (unit + snapshot) @@ -270,11 +359,17 @@ xcodebuild test \ -scheme "Challenge (Dev)" \ -testPlan Challenge \ -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=latest' +``` + +UI tests always use Tuist: +```bash # Run UI tests mise x -- tuist test "ChallengeUITests" ``` +Always use `xcodebuild test` for module tests (not `tuist test`). + ## Commands ```bash @@ -300,3 +395,69 @@ mise x -- tuist test "ChallengeUITests" # Clean and regenerate mise x -- tuist clean && mise x -- tuist generate ``` + +## Derived Folder + +Tuist generates files in the `Derived/` folder: + +``` +Derived/ +└── InfoPlists/ + ├── Challenge-Info.plist + └── ChallengeUITests-Info.plist +``` + +**Contents:** Only `Info.plist` files for the app and UI tests targets. + +**Git:** The `Derived/` folder is in `.gitignore`. + +## Adding an XCFramework as a Dependency + +### 1. XCFramework Location + +Place the `.xcframework` file in the `Tuist/Dependencies/` directory: + +``` +Challenge/ +├── Tuist/ +│ ├── Dependencies/ +│ │ └── FrameworkName.xcframework +│ └── ProjectDescriptionHelpers/ +├── Libraries/ +├── App/ +└── Project.swift +``` + +> **Note:** The `Tuist/Dependencies/` directory is ignored by git. Do not commit xcframeworks to the repository. + +### 2. Create the XCFrameworks Helper + +If it doesn't exist, create the file `Tuist/ProjectDescriptionHelpers/Dependencies.swift`: + +```swift +import ProjectDescription + +public enum XCFrameworks { + public static let frameworkName: TargetDependency = .xcframework(path: "Tuist/Dependencies/FrameworkName.xcframework") +} +``` + +### 3. Add the Dependency to a Module + +Use `.external()` or add directly to the module's dependencies: + +```swift +public let myModule = Module( + directory: "Features/MyFeature", + dependencies: [ + .module(coreModule), + // XCFramework dependency would go in the target directly + ] +) +``` + +### 4. Regenerate the Project + +```bash +./generate.sh +``` diff --git a/generate.sh b/generate.sh index 74677c69..8885b114 100755 --- a/generate.sh +++ b/generate.sh @@ -1,24 +1,52 @@ #!/bin/zsh +DEFAULT_STRATEGY="framework" + usage() { - echo "Usage: ./generate.sh [--clean]" + echo "Usage: ./generate.sh [--clean] [--strategy ]" echo "" echo "Generate the Xcode project." echo "" echo "Options:" - echo " --clean Clean Tuist cache and reinstall dependencies before generating" + echo " --clean Clean Tuist cache and reinstall dependencies before generating" + echo " --strategy Module integration strategy (default: $DEFAULT_STRATEGY)" exit 1 } -if [[ "$1" == "--help" || "$1" == "-h" ]]; then - usage -fi +CLEAN=false +STRATEGY="$DEFAULT_STRATEGY" + +while [[ $# -gt 0 ]]; do + case "$1" in + --clean) + CLEAN=true + shift + ;; + --strategy) + if [[ -z "$2" || "$2" == --* ]]; then + echo "Error: --strategy requires a value (spm or framework)" + exit 1 + fi + STRATEGY="$2" + shift 2 + ;; + --help|-h) + usage + ;; + *) + usage + ;; + esac +done -if [[ $# -gt 1 || ( $# -eq 1 && "$1" != "--clean" ) ]]; then - usage +if [[ "$STRATEGY" != "spm" && "$STRATEGY" != "framework" ]]; then + echo "Error: Invalid strategy '$STRATEGY'. Valid values: spm, framework" + exit 1 fi -if [[ "$1" == "--clean" ]]; then +export TUIST_MODULE_STRATEGY="$STRATEGY" + +if [[ "$CLEAN" == true ]]; then echo "Cleaning Tuist cache..." mise x -- tuist clean plugins generatedAutomationProjects projectDescriptionHelpers manifests editProjects runs binaries selectiveTests dependencies @@ -35,5 +63,5 @@ mise x -- tuist install echo "Removing previous generated project..." find . -path ./Tuist -prune -o -name "*.xcodeproj" -print -exec rm -rf {} + 2>/dev/null -echo "Generating project..." +echo "Generating project (strategy: $STRATEGY)..." mise x -- tuist generate