diff --git a/.github/workflows/VerifyChanges.yaml b/.github/workflows/VerifyChanges.yaml index feb526d..e68617a 100644 --- a/.github/workflows/VerifyChanges.yaml +++ b/.github/workflows/VerifyChanges.yaml @@ -1,5 +1,4 @@ name: Verify Changes - on: merge_group: pull_request: @@ -10,7 +9,7 @@ on: jobs: lint: name: Lint - runs-on: macos-15 + runs-on: macos-26 steps: - name: Checkout uses: actions/checkout@v4 @@ -20,10 +19,11 @@ jobs: - name: Lint run: | Scripts/lint + build-and-test: name: Build and Test (${{ matrix.platform }}) needs: lint - runs-on: macos-15 + runs-on: macos-26 strategy: fail-fast: false matrix: @@ -44,31 +44,40 @@ jobs: # xcode_destination: "platform=watchOS Simulator,name=GitHub_Actions_Simulator" # simulator_device_type: "com.apple.CoreSimulator.SimDeviceType.Apple-Watch-Series-10-46mm" # simulator_runtime: "com.apple.CoreSimulator.SimRuntime.watchOS-26-0" + env: DEV_BUILDS: DevBuilds/Sources + OTHER_XCBEAUTIFY_FLAGS: --renderer github-actions XCCOV_PRETTY_VERSION: 1.2.0 XCODE_SCHEME: DevFoundation-Package XCODE_DESTINATION: ${{ matrix.xcode_destination }} XCODE_TEST_PLAN: AllTests + XCODE_TEST_PRODUCTS_PATH: .build/DevFoundation.xctestproducts + steps: + - name: Select Xcode 26.0.0 + run: | + sudo xcode-select -s /Applications/Xcode_26.0.0.app + - name: Checkout uses: actions/checkout@v4 + - name: Checkout DevBuilds uses: actions/checkout@v4 with: repository: DevKitOrganization/DevBuilds path: DevBuilds - - name: Download xccovPretty + + - name: Restore XCTestProducts if: github.event_name != 'push' - run: | - gh release download ${{ env.XCCOV_PRETTY_VERSION }} \ - --repo DevKitOrganization/xccovPretty \ - --pattern "xccovPretty-macos.tar.gz" \ - -O - | tar -xz - chmod +x xccovPretty - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + id: cache-xctestproducts-restore + uses: actions/cache/restore@v4 + with: + path: ${{ env.XCODE_TEST_PRODUCTS_PATH }} + key: cache-xctestproducts-${{ github.workflow }}-${{ matrix.platform }}-${{ github.sha }} + - uses: irgaly/xcode-cache@v1 + if: steps.cache-xctestproducts-restore.outputs.cache-hit != 'true' with: key: xcode-cache-deriveddata-${{ github.workflow }}-${{ matrix.platform }}-${{ github.sha }} restore-keys: | @@ -78,42 +87,50 @@ jobs: sourcepackages-directory: .build/DerivedData/SourcePackages swiftpm-package-resolved-file: Package.resolved verbose: true - - name: Select Xcode 26.0.0 - run: | - sudo xcode-select -s /Applications/Xcode_26.0.0.app - - name: Create Simulator - if: ${{ matrix.platform != 'macOS' }} - run: | - xcrun simctl create GitHub_Actions_Simulator \ - ${{ matrix.simulator_device_type }} \ - ${{ matrix.simulator_runtime }} + - name: Build for Testing - run: | - "$DEV_BUILDS"/build_and_test.sh --action build-for-testing - - name: Test + id: build-for-testing + if: steps.cache-xctestproducts-restore.outputs.cache-hit != 'true' + run: ${{ env.DEV_BUILDS }}/build_and_test.sh --action build-for-testing + + - name: Test Without Building + id: test-without-building if: github.event_name != 'push' - run: | - "$DEV_BUILDS"/build_and_test.sh --action test + run: ${{ env.DEV_BUILDS }}/build_and_test.sh --action test-without-building + + - name: Save XCTestProducts + if: failure() && steps.cache-xctestproducts-restore.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ${{ env.XCODE_TEST_PRODUCTS_PATH }} + key: ${{ steps.cache-xctestproducts-restore.outputs.cache-primary-key }} + - name: Log Code Coverage if: github.event_name != 'push' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - xcrun xccov view --report .build/DevFoundation-Package_test.xcresult --json \ - | ./xccovPretty --github-comment \ - > .build/xccovPretty-${{ matrix.platform }}.output - - name: Upload Logs - uses: actions/upload-artifact@v4 + gh release download ${{ env.XCCOV_PRETTY_VERSION }} \ + --repo DevKitOrganization/xccovPretty \ + --pattern "xccovPretty-macos.tar.gz" \ + -O - \ + | tar -xz + chmod +x xccovPretty + + xcrun xccov view --report .build/${XCODE_SCHEME}_test-without-building.xcresult --json \ + | ./xccovPretty --github-comment \ + > .build/xccovPretty-${{ matrix.platform }}.output + + - name: Upload Logs and XCResults if: success() || failure() - with: - name: Logs-${{ matrix.platform }} - path: .build/*.log - include-hidden-files: true - - name: Upload XCResults uses: actions/upload-artifact@v4 - if: success() || failure() with: - name: XCResults-${{ matrix.platform }} - path: .build/*.xcresult + name: Logs_and_XCResults-${{ matrix.platform }} + path: | + .build/*.log + .build/*.xcresult include-hidden-files: true + - name: Upload xccovPretty output if: github.event_name != 'push' uses: actions/upload-artifact@v4 @@ -121,18 +138,21 @@ jobs: name: xccovPrettyOutput-${{ matrix.platform }} path: .build/xccovPretty-${{ matrix.platform }}.output include-hidden-files: true + post-pr-comments: name: Post PR Comments needs: build-and-test + if: ${{ github.event_name == 'pull_request' }} permissions: pull-requests: write runs-on: ubuntu-latest - if: ${{ github.event_name == 'pull_request' }} + steps: - name: Download xccovPretty output uses: actions/download-artifact@v4 with: name: xccovPrettyOutput-macOS + - name: Post Code Coverage Comment uses: thollander/actions-comment-pull-request@v3 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 787b858..6655da0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,12 +3,17 @@ ## 1.2.0: September 24, 2025 -This release contains updates to `ExecutionGroup` to enable greater flexibility and testability. - -There are now two variants of `addTask(priority:operation:)`: the original version for non-throwing -operations, and a new version for throwing operations. Both functions now have a generic parameter -for the operation’s return type. Together, these changes allow us to return the created `Task`, -which you can use to monitor its progress, get its result, or cancel it. +This release introduces the `ObservableReference` type and updates `ExecutionGroup` to enable +greater flexibility and testability. + + - This version updates the minimum supported versions of Apple’s OSes to 26. + - `ObservableReference` is a simple reference type that conforms to `Observable`. This enables + easily observing changes to the value. + - There are now two variants of `ExecutionGroup.addTask(priority:operation:)`: the original + version for non-throwing operations, and a new version for throwing operations. Both functions + now have a generic parameter for the operation’s return type. Together, these changes allow us + to return the created `Task`, which you can use to monitor its progress, get its result, or + cancel it. ## 1.1.0: September 17, 2025 diff --git a/CLAUDE.md b/CLAUDE.md index 83e6cf3..2c760b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -217,7 +217,7 @@ Follow the project's Markdown Style Guide: - Follows Swift API Design Guidelines - Uses Swift 6.2 with `ExistentialAny` and `MemberImportVisibility` features enabled - - Minimum deployment targets: iOS 18+, macOS 15+, tvOS 18+, visionOS 2+, watchOS 11+ + - Minimum deployment targets: iOS, macOS, tvOS, visionOS, and watchOS 26 - Reverse DNS prefix: `com.gauriar.devfoundation` - All public APIs are documented and tested - Test coverage target: >99% diff --git a/Package.resolved b/Package.resolved index e07f3f3..fe715fb 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "6900337c066f271eafd142ee102022d6420563e3f0b1a82f516904f3fc771e74", + "originHash" : "f5b6a4870c89f10d1a64f4ddeeccc767b0b38eca2482b4883a3f9ead13466e4b", "pins" : [ { "identity" : "devtesting", "kind" : "remoteSourceControl", "location" : "https://github.com/DevKitOrganization/DevTesting", "state" : { - "revision" : "fe4ecf8f5d110496da792a3b8e4cabc27a590371", - "version" : "1.1.0" + "revision" : "7dcd0757a4fa146ffeede688d649953643014be9", + "version" : "1.2.0" } }, { diff --git a/Package.swift b/Package.swift index 012af2a..92283d0 100644 --- a/Package.swift +++ b/Package.swift @@ -10,11 +10,11 @@ let swiftSettings: [SwiftSetting] = [ let package = Package( name: "DevFoundation", platforms: [ - .iOS(.v18), - .macOS(.v15), - .tvOS(.v18), - .visionOS(.v2), - .watchOS(.v11), + .iOS(.v26), + .macOS(.v26), + .tvOS(.v26), + .visionOS(.v26), + .watchOS(.v26), ], products: [ .library( @@ -32,7 +32,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.6.1"), .package(url: "https://github.com/apple/swift-numerics.git", from: "1.1.0"), - .package(url: "https://github.com/DevKitOrganization/DevTesting", from: "1.1.0"), + .package(url: "https://github.com/DevKitOrganization/DevTesting", from: "1.2.0"), .package(url: "https://github.com/prachigauriar/URLMock.git", from: "1.3.6"), ], targets: [ diff --git a/README.md b/README.md index faaeff4..3adc9ff 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,8 @@ DevFoundation is a Swift 6 package that provides useful types and protocols for and apps on Apple platforms. It makes it easy to consume web services, mock web service responses, page through data, post and handle type-safe events across multiple modules, and more. -DevFoundation is fully documented and tested and supports iOS 18+, macOS 15+, tvOS 18+, visionOS 2+, -and watchOS 11+. +DevFoundation is fully documented and tested and supports iOS 26+, macOS 26+, tvOS 26+, visionOS 26+, +and watchOS 26+. View our [changelog](CHANGELOG.md) to see what’s new. diff --git a/Sources/DevFoundation/Concurrency/ExecutionGroup.swift b/Sources/DevFoundation/Concurrency/ExecutionGroup.swift index 8030a6d..365ccce 100644 --- a/Sources/DevFoundation/Concurrency/ExecutionGroup.swift +++ b/Sources/DevFoundation/Concurrency/ExecutionGroup.swift @@ -116,26 +116,16 @@ public final class ExecutionGroup: Sendable { /// Increments the group’s task count and mutates its execution state if needed. private func incrementTaskCount() { - let isFirstTask = taskCount.withLock { (count) in - count += 1 - return count == 1 - } - - if isFirstTask { - withMutation(keyPath: \.isExecuting) { () } + withMutation(keyPath: \.isExecuting) { + taskCount.withLock { $0 += 1 } } } /// Decrements the group’s task count and mutates its execution state if needed. private func decrementTaskCount() { - let isLastTask = taskCount.withLock { (count) in - count -= 1 - return count == 0 - } - - if isLastTask { - withMutation(keyPath: \.isExecuting) { () } + withMutation(keyPath: \.isExecuting) { + taskCount.withLock { $0 -= 1 } } } } diff --git a/Sources/DevFoundation/Documentation.docc/Documentation.md b/Sources/DevFoundation/Documentation.docc/Documentation.md index 73dd583..8b8cbfb 100644 --- a/Sources/DevFoundation/Documentation.docc/Documentation.md +++ b/Sources/DevFoundation/Documentation.docc/Documentation.md @@ -28,6 +28,10 @@ for paging through data, and essential utility types for building robust applica - ``ExpiringValue`` +### Observing Changes + +- ``ObservableReference`` + ### Concurrency Utilities - ``ExecutionGroup`` diff --git a/Sources/DevFoundation/Utility Types/ObservableReference.swift b/Sources/DevFoundation/Utility Types/ObservableReference.swift new file mode 100644 index 0000000..01990a1 --- /dev/null +++ b/Sources/DevFoundation/Utility Types/ObservableReference.swift @@ -0,0 +1,41 @@ +// +// ObservableReference.swift +// DevFoundation +// +// Created by Prachi Gauriar on 9/24/25. +// + +import Foundation +import Synchronization + +/// A reference whose value changes can be observed. +@Observable +public final class ObservableReference: Sendable where Value: Sendable { + /// A mutex that synchronizes access to the reference’s value. + private let valueMutex: Mutex + + + /// Creates a new observable reference with the specified initial value. + /// + /// - Parameter initialValue: The initial value that the reference contains. + public init(_ initialValue: Value) { + self.valueMutex = .init(initialValue) + } + + + /// The reference’s value. + public var value: Value { + get { + access(keyPath: \.value) + return valueMutex.withLock { $0 } + } + + set { + withMutation(keyPath: \.value) { + valueMutex.withLock { (value) in + value = newValue + } + } + } + } +} diff --git a/Tests/DevFoundationTests/Concurrency/ExecutionGroupTests.swift b/Tests/DevFoundationTests/Concurrency/ExecutionGroupTests.swift index 4b6aba4..36e7925 100644 --- a/Tests/DevFoundationTests/Concurrency/ExecutionGroupTests.swift +++ b/Tests/DevFoundationTests/Concurrency/ExecutionGroupTests.swift @@ -16,45 +16,45 @@ struct ExecutionGroupTests { let group = ExecutionGroup() #expect(!group.isExecuting) - try await confirmation("task is executed", expectedCount: 2) { (didExecute) in - try await confirmation("emits isExecuting observation events", expectedCount: 2) { (didObserve) in - withObservationTracking { - // Is executing should initially be false - #expect(!group.isExecuting) - } onChange: { - // It should change to true - didObserve() - withObservationTracking { - #expect(group.isExecuting) - } onChange: { - // It should change back to false - didObserve() - #expect(!group.isExecuting) - } + let observationTask = Task { + var observedValues: [Bool] = [] + for await observation in Observations({ group.isExecuting }) { + observedValues.append(observation) + + print(observedValues) + if !observation { + break } + } - // Start two tasks - let task1 = group.addTask { - // While the task is running, isExecuting should be true - didExecute() - #expect(group.isExecuting) - try? await Task.sleep(for: .milliseconds(250)) - - } + #expect((1 ... 2).contains(observedValues.count { $0 == true })) + #expect(observedValues.count { $0 == false } == 1) + } - let task2 = group.addTask { - // While the task is running, isExecuting should be true - didExecute() - #expect(group.isExecuting) - try await Task.sleep(for: .milliseconds(250)) - } + try await confirmation("task is executed", expectedCount: 2) { (didExecute) in + // Start two tasks + let task1 = group.addTask { + // While the task is running, isExecuting should be true + didExecute() + #expect(group.isExecuting) + try? await Task.sleep(for: .milliseconds(100)) - // Wait for the tasks to finish - let _ = (await task1.value, try await task2.value) + } - // When the tasks are finished, isExecuting should be false - #expect(!group.isExecuting) + let task2 = group.addTask { + // While the task is running, isExecuting should be true + didExecute() + #expect(group.isExecuting) + try await Task.sleep(for: .milliseconds(200)) } + + // Wait for the tasks to finish + let _ = (await task1.value, try await task2.value) + + // When the tasks are finished, isExecuting should be false + #expect(!group.isExecuting) } + + await observationTask.value } } diff --git a/Tests/DevFoundationTests/Utility Types/ObservableReferenceTests.swift b/Tests/DevFoundationTests/Utility Types/ObservableReferenceTests.swift new file mode 100644 index 0000000..a0a7f76 --- /dev/null +++ b/Tests/DevFoundationTests/Utility Types/ObservableReferenceTests.swift @@ -0,0 +1,47 @@ +// +// ObservableReferenceTests.swift +// DevFoundation +// +// Created by Prachi Gauriar on 9/24/25. +// + +import DevFoundation +import DevTesting +import Foundation +import Testing + +struct ObservableReferenceTests: RandomValueGenerating { + var randomNumberGenerator = makeRandomNumberGenerator() + + + @Test + mutating func observability() async { + let initialValue = randomInt(in: .min ... .max) + let changes = Array(count: 3) { randomInt(in: .min ... .max) } + let expectedObservedValues = [initialValue] + changes + + let reference = ObservableReference(initialValue) + + let observationTask = Task { + var observedValues: [Int] = [] + for await observation in Observations({ reference.value }) { + observedValues.append(observation) + + if observedValues.count == expectedObservedValues.count { + break + } + } + + #expect(observedValues == expectedObservedValues) + } + + Task { + for value in changes { + try? await Task.sleep(for: .milliseconds(100)) + reference.value = value + } + } + + await observationTask.value + } +}