From fa90d37668eb44a0828bdad55f224f47656e574e Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Wed, 8 Apr 2026 19:02:07 -0400 Subject: [PATCH 01/10] chore(ci): migrate workflow to SyntaxKit structure with strict lint enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add configure job for dynamic matrix selection (full vs minimal by branch) - Split build-macos into build-macos (SPM only) + build-macos-full (full platform matrix) - Add concurrency group with cancel-in-progress - Update triggers: branches-ignore → push/pull_request with paths-ignore - Update action versions: checkout@v6, swift-build@v1, swift-coverage-action@v5, codecov@v6 - Move LINT_MODE=STRICT from job env to step env (matches SundialKit fix) - Update mise-action@v2 → @v4 with simplified config - Gate coverage steps with contains-code-coverage output check Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/SundialKitStream.yml | 225 ++++++++++++++++--------- Package.resolved | 6 +- Package.swift | 2 +- 3 files changed, 147 insertions(+), 86 deletions(-) diff --git a/.github/workflows/SundialKitStream.yml b/.github/workflows/SundialKitStream.yml index 6de6493..cf918a3 100644 --- a/.github/workflows/SundialKitStream.yml +++ b/.github/workflows/SundialKitStream.yml @@ -1,155 +1,216 @@ name: SundialKitStream on: push: - branches-ignore: - - '*WIP' + branches: + - main + tags: + - 'v[0-9]*.[0-9]*.[0-9]*' + paths-ignore: + - '**.md' + - 'Docs/**' + - 'LICENSE' + - '.github/ISSUE_TEMPLATE/**' + pull_request: + branches: + - main + - 'v[0-9]*.[0-9]*.[0-9]*' + paths-ignore: + - '**.md' + - 'Docs/**' + - 'LICENSE' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: true + env: PACKAGE_NAME: SundialKitStream jobs: + configure: + name: Configure Build Matrix + runs-on: ubuntu-latest + if: ${{ github.event_name == 'pull_request' || !contains(github.event.head_commit.message, 'ci skip') }} + outputs: + full-matrix: ${{ steps.set-matrix.outputs.full-matrix }} + ubuntu-os: ${{ steps.set-matrix.outputs.ubuntu-os }} + ubuntu-swift: ${{ steps.set-matrix.outputs.ubuntu-swift }} + steps: + - name: Determine build matrix + id: set-matrix + run: | + if [[ "${{ github.ref }}" == "refs/heads/main" || "${{ github.event_name }}" == "pull_request" ]]; then + echo "full-matrix=true" >> "$GITHUB_OUTPUT" + echo 'ubuntu-os=["noble","jammy"]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-swift=[{"version":"6.1"},{"version":"6.2"},{"version":"6.3"}]' >> "$GITHUB_OUTPUT" + else + echo "full-matrix=false" >> "$GITHUB_OUTPUT" + echo 'ubuntu-os=["noble"]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-swift=[{"version":"6.3"}]' >> "$GITHUB_OUTPUT" + fi + build-ubuntu: name: Build on Ubuntu + needs: [configure] runs-on: ubuntu-latest container: ${{ matrix.swift.nightly && format('swiftlang/swift:nightly-{0}-{1}', matrix.swift.version, matrix.os) || format('swift:{0}-{1}', matrix.swift.version, matrix.os) }} - if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} strategy: fail-fast: false matrix: - os: [noble, jammy] - swift: - - version: "6.1" - - version: "6.2" - - version: "6.1" - nightly: true - - version: "6.2" - nightly: true + os: ${{ fromJSON(needs.configure.outputs.ubuntu-os) }} + swift: ${{ fromJSON(needs.configure.outputs.ubuntu-swift) }} steps: - - uses: actions/checkout@v4 - - uses: brightdigit/swift-build@v1.4.0 - with: - scheme: ${{ env.PACKAGE_NAME }} - - uses: sersoft-gmbh/swift-coverage-action@v4 + - uses: actions/checkout@v6 + - uses: brightdigit/swift-build@v1 + id: build + - name: Install curl + if: steps.build.outputs.contains-code-coverage == 'true' + run: | + apt-get update -q + apt-get install -y curl + - uses: sersoft-gmbh/swift-coverage-action@v5 id: coverage-files + if: steps.build.outputs.contains-code-coverage == 'true' with: fail-on-empty-output: true - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 with: fail_ci_if_error: true - flags: swift-${{ matrix.swift.version }}-${{ matrix.os }}${{ matrix.swift.nightly && '-nightly' || '' }} + flags: swift-${{ matrix.swift.version }},ubuntu verbose: true token: ${{ secrets.CODECOV_TOKEN }} files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + build-macos: name: Build on macOS + needs: [configure] + env: + PACKAGE_NAME: SundialKitStream runs-on: ${{ matrix.runs-on }} - if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + if: ${{ !cancelled() && needs.configure.result == 'success' }} strategy: fail-fast: false matrix: include: - # SPM Build Matrix - Xcode 16.x+ (Swift 6.1+) + # SPM Build — no platform type; matrix.type evaluates to '' by design - runs-on: macos-26 - xcode: "/Applications/Xcode_26.1.app" - - runs-on: macos-26 - xcode: "/Applications/Xcode_26.0.app" + xcode: "/Applications/Xcode_26.4.app" + + steps: + - uses: actions/checkout@v6 + + - name: Build and Test + id: build + uses: brightdigit/swift-build@v1 + with: + scheme: ${{ env.PACKAGE_NAME }}-Package + type: ${{ matrix.type }} + xcode: ${{ matrix.xcode }} + deviceName: ${{ matrix.deviceName }} + osVersion: ${{ matrix.osVersion }} + download-platform: ${{ matrix.download-platform }} + + # Common Coverage Steps + - name: Process Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 + + - name: Upload Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ matrix.type && format('{0}{1}', matrix.type, matrix.osVersion) || 'spm' }} + + build-macos-full: + name: Build on macOS (Full) + needs: [configure] + if: ${{ !cancelled() && needs.configure.result == 'success' && needs.configure.outputs.full-matrix == 'true' }} + env: + PACKAGE_NAME: SundialKitStream + runs-on: ${{ matrix.runs-on }} + strategy: + fail-fast: false + matrix: + include: + # SPM Build (older Xcode) - runs-on: macos-15 xcode: "/Applications/Xcode_16.4.app" - - runs-on: macos-15 - xcode: "/Applications/Xcode_16.3.app" - # iOS Build Matrix - Xcode 16.x+ (Swift 6.1+) - - type: ios + # macOS Build (older Xcode) + - type: macos + runs-on: macos-15 + xcode: "/Applications/Xcode_16.4.app" + + # macOS Build + - type: macos runs-on: macos-26 - xcode: "/Applications/Xcode_26.1.app" - deviceName: "iPhone 17 Pro" - osVersion: "26.1" - download-platform: true + xcode: "/Applications/Xcode_26.4.app" + # iOS Build Matrix - type: ios runs-on: macos-26 - xcode: "/Applications/Xcode_26.0.app" + xcode: "/Applications/Xcode_26.4.app" deviceName: "iPhone 17 Pro" - osVersion: "26.0.1" + osVersion: "26.4" download-platform: true - - type: ios - runs-on: macos-15 - xcode: "/Applications/Xcode_16.4.app" - deviceName: "iPhone 16e" - osVersion: "18.5" - - - type: ios - runs-on: macos-15 - xcode: "/Applications/Xcode_16.3.app" - deviceName: "iPhone 16" - osVersion: "18.4" - - # watchOS Build Matrix - Xcode 16.x+ (Swift 6.1+) + # watchOS Build Matrix - type: watchos runs-on: macos-26 - xcode: "/Applications/Xcode_26.1.app" + xcode: "/Applications/Xcode_26.4.app" deviceName: "Apple Watch Ultra 3 (49mm)" - osVersion: "26.0" + osVersion: "26.4" download-platform: true - - type: watchos + # tvOS Build Matrix + - type: tvos runs-on: macos-26 - xcode: "/Applications/Xcode_26.0.app" - deviceName: "Apple Watch Ultra 3 (49mm)" - osVersion: "26.0" + xcode: "/Applications/Xcode_26.4.app" + deviceName: "Apple TV" + osVersion: "26.4" download-platform: true - - type: watchos - runs-on: macos-15 - xcode: "/Applications/Xcode_16.4.app" - deviceName: "Apple Watch Series 10 (46mm)" - osVersion: "11.5" - - - type: watchos - runs-on: macos-15 - xcode: "/Applications/Xcode_16.3.app" - deviceName: "Apple Watch Series 10 (42mm)" - osVersion: "11.4" - steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Build and Test - uses: brightdigit/swift-build@v1.4.0 + id: build + uses: brightdigit/swift-build@v1 with: - scheme: ${{ env.PACKAGE_NAME }} + scheme: ${{ env.PACKAGE_NAME }}-Package type: ${{ matrix.type }} xcode: ${{ matrix.xcode }} deviceName: ${{ matrix.deviceName }} osVersion: ${{ matrix.osVersion }} download-platform: ${{ matrix.download-platform }} - # Coverage Steps + # Common Coverage Steps - name: Process Coverage - uses: sersoft-gmbh/swift-coverage-action@v4 + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 - name: Upload Coverage - uses: codecov/codecov-action@v4 + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} flags: ${{ matrix.type && format('{0}{1}', matrix.type, matrix.osVersion) || 'spm' }} lint: name: Linting - if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + # build-macos-full is skipped on feature branches; + # !failure() allows this job to proceed past skipped (not failed) dependencies. + if: ${{ !cancelled() && !failure() && (github.event_name == 'pull_request' || !contains(github.event.head_commit.message, 'ci skip')) }} runs-on: ubuntu-latest - needs: [build-ubuntu, build-macos] - env: - LINT_MODE: STRICT + needs: [build-ubuntu, build-macos, build-macos-full] steps: - - uses: actions/checkout@v4 - - name: Install mise - uses: jdx/mise-action@v2 + - uses: actions/checkout@v6 + - uses: jdx/mise-action@v4 with: - version: 2024.11.0 - install: true cache: true - name: Lint - run: | - set -e - ./Scripts/lint.sh + run: ./Scripts/lint.sh + env: + LINT_MODE: STRICT diff --git a/Package.resolved b/Package.resolved index 6a0c3f6..cfa3e6e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "bb26fa541c8043161a229c70d629895a66f222ad1353a6f5a22506d5b8fa4241", + "originHash" : "4551de46d8358d6c9a07c40cfea6fde49dfd9b083cdc20f13d4194e6d189dc19", "pins" : [ { "identity" : "sundialkit", "kind" : "remoteSourceControl", "location" : "https://github.com/brightdigit/SundialKit.git", "state" : { - "revision" : "ff0e3f28e61107d26405c05ec1fa9637dbce05ed", - "version" : "2.0.0-alpha.1" + "branch" : "atleast-v1.0.0-beta.4", + "revision" : "7a08cc684146914e3f6661d328d4a51d5226be16" } } ], diff --git a/Package.swift b/Package.swift index 5b09d31..45367f6 100644 --- a/Package.swift +++ b/Package.swift @@ -59,7 +59,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/brightdigit/SundialKit.git", from: "2.0.0-alpha.1") + .package(url: "https://github.com/brightdigit/SundialKit.git", branch: "atleast-v1.0.0-beta.4") ], targets: [ .target( From ef714ecacf137fd96cdbaaf935110cb8510e391d Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Wed, 8 Apr 2026 19:11:08 -0400 Subject: [PATCH 02/10] fixing target name --- .github/workflows/SundialKitStream.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/SundialKitStream.yml b/.github/workflows/SundialKitStream.yml index cf918a3..2d6e29a 100644 --- a/.github/workflows/SundialKitStream.yml +++ b/.github/workflows/SundialKitStream.yml @@ -104,7 +104,7 @@ jobs: id: build uses: brightdigit/swift-build@v1 with: - scheme: ${{ env.PACKAGE_NAME }}-Package + scheme: ${{ env.PACKAGE_NAME }} type: ${{ matrix.type }} xcode: ${{ matrix.xcode }} deviceName: ${{ matrix.deviceName }} @@ -179,7 +179,7 @@ jobs: id: build uses: brightdigit/swift-build@v1 with: - scheme: ${{ env.PACKAGE_NAME }}-Package + scheme: ${{ env.PACKAGE_NAME }} type: ${{ matrix.type }} xcode: ${{ matrix.xcode }} deviceName: ${{ matrix.deviceName }} From 50445032df90eacd0fd7b17b3a376a5007f36587 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Wed, 8 Apr 2026 19:17:34 -0400 Subject: [PATCH 03/10] chore(ci): add WASM types, Windows, Android builds and restore older iOS/watchOS - Add ubuntu-type output to configure job (wasm, wasm-embedded) - Wire ubuntu-type into build-ubuntu matrix, excluding Swift 6.1 from wasm types - Add build-windows job (windows-2022, windows-2025; Swift 6.1/6.2/6.3) - Add build-android job (Swift 6.2/6.3; API levels 33-36) - Remove macOS type entries from build-macos-full - Restore older iOS/watchOS builds on macos-15 with Xcode_16.4.app - Update lint job needs to include build-windows and build-android Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/SundialKitStream.yml | 114 ++++++++++++++++++++++--- 1 file changed, 102 insertions(+), 12 deletions(-) diff --git a/.github/workflows/SundialKitStream.yml b/.github/workflows/SundialKitStream.yml index 2d6e29a..f6aab7e 100644 --- a/.github/workflows/SundialKitStream.yml +++ b/.github/workflows/SundialKitStream.yml @@ -34,6 +34,7 @@ jobs: full-matrix: ${{ steps.set-matrix.outputs.full-matrix }} ubuntu-os: ${{ steps.set-matrix.outputs.ubuntu-os }} ubuntu-swift: ${{ steps.set-matrix.outputs.ubuntu-swift }} + ubuntu-type: ${{ steps.set-matrix.outputs.ubuntu-type }} steps: - name: Determine build matrix id: set-matrix @@ -42,10 +43,12 @@ jobs: echo "full-matrix=true" >> "$GITHUB_OUTPUT" echo 'ubuntu-os=["noble","jammy"]' >> "$GITHUB_OUTPUT" echo 'ubuntu-swift=[{"version":"6.1"},{"version":"6.2"},{"version":"6.3"}]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-type=["","wasm","wasm-embedded"]' >> "$GITHUB_OUTPUT" else echo "full-matrix=false" >> "$GITHUB_OUTPUT" echo 'ubuntu-os=["noble"]' >> "$GITHUB_OUTPUT" echo 'ubuntu-swift=[{"version":"6.3"}]' >> "$GITHUB_OUTPUT" + echo 'ubuntu-type=[""]' >> "$GITHUB_OUTPUT" fi build-ubuntu: @@ -58,10 +61,18 @@ jobs: matrix: os: ${{ fromJSON(needs.configure.outputs.ubuntu-os) }} swift: ${{ fromJSON(needs.configure.outputs.ubuntu-swift) }} + type: ${{ fromJSON(needs.configure.outputs.ubuntu-type) }} + exclude: + - swift: {version: "6.1"} + type: wasm + - swift: {version: "6.1"} + type: wasm-embedded steps: - uses: actions/checkout@v6 - uses: brightdigit/swift-build@v1 id: build + with: + type: ${{ matrix.type }} - name: Install curl if: steps.build.outputs.contains-code-coverage == 'true' run: | @@ -138,16 +149,6 @@ jobs: - runs-on: macos-15 xcode: "/Applications/Xcode_16.4.app" - # macOS Build (older Xcode) - - type: macos - runs-on: macos-15 - xcode: "/Applications/Xcode_16.4.app" - - # macOS Build - - type: macos - runs-on: macos-26 - xcode: "/Applications/Xcode_26.4.app" - # iOS Build Matrix - type: ios runs-on: macos-26 @@ -156,6 +157,12 @@ jobs: osVersion: "26.4" download-platform: true + - type: ios + runs-on: macos-15 + xcode: "/Applications/Xcode_16.4.app" + deviceName: "iPhone 16e" + osVersion: "18.5" + # watchOS Build Matrix - type: watchos runs-on: macos-26 @@ -164,6 +171,12 @@ jobs: osVersion: "26.4" download-platform: true + - type: watchos + runs-on: macos-15 + xcode: "/Applications/Xcode_16.4.app" + deviceName: "Apple Watch Series 10 (46mm)" + osVersion: "11.5" + # tvOS Build Matrix - type: tvos runs-on: macos-26 @@ -198,13 +211,90 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} flags: ${{ matrix.type && format('{0}{1}', matrix.type, matrix.osVersion) || 'spm' }} + build-windows: + name: Build on Windows + needs: [configure] + if: needs.configure.outputs.full-matrix == 'true' + runs-on: ${{ matrix.runs-on }} + strategy: + fail-fast: false + matrix: + runs-on: [windows-2022, windows-2025] + swift: + - version: swift-6.3-release + build: 6.3-RELEASE + - version: swift-6.2-release + build: 6.2-RELEASE + - version: swift-6.1-release + build: 6.1-RELEASE + steps: + - uses: actions/checkout@v6 + - name: Enable git symlinks + shell: pwsh + run: git config --global core.symlinks true + - uses: brightdigit/swift-build@v1 + id: build + with: + windows-swift-version: ${{ matrix.swift.version }} + windows-swift-build: ${{ matrix.swift.build }} + - name: Upload coverage to Codecov + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + fail_ci_if_error: true + flags: swift-${{ matrix.swift.version }},windows + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + os: windows + swift_project: SundialKitStream + + build-android: + name: Build for Android + needs: [configure] + if: needs.configure.outputs.full-matrix == 'true' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + swift: + - version: "6.2" + - version: "6.3" + android-api-level: [33, 34, 35, 36] + steps: + - uses: actions/checkout@v6 + - name: Free disk space + uses: jlumbroso/free-disk-space@v1.3.1 + with: + tool-cache: false + android: false + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true + - uses: brightdigit/swift-build@v1 + id: build + with: + type: android + android-swift-version: ${{ matrix.swift.version }} + android-api-level: ${{ matrix.android-api-level }} + android-run-tests: true + - name: Upload coverage to Codecov + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + fail_ci_if_error: true + flags: android-api${{ matrix.android-api-level }}-swift${{ matrix.swift.version }} + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + lint: name: Linting - # build-macos-full is skipped on feature branches; + # build-windows, build-macos-full, and build-android are skipped on feature branches; # !failure() allows this job to proceed past skipped (not failed) dependencies. if: ${{ !cancelled() && !failure() && (github.event_name == 'pull_request' || !contains(github.event.head_commit.message, 'ci skip')) }} runs-on: ubuntu-latest - needs: [build-ubuntu, build-macos, build-macos-full] + needs: [build-ubuntu, build-macos, build-windows, build-macos-full, build-android] steps: - uses: actions/checkout@v6 - uses: jdx/mise-action@v4 From 7036628ac38efe15c3faddc188b60cd08cb4347d Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Wed, 8 Apr 2026 19:38:06 -0400 Subject: [PATCH 04/10] fix(wasm): exclude Dispatch-dependent code for WASM compatibility - Wrap NetworkObserver.start(queue:) in #if canImport(Dispatch) - Wrap MockPathMonitor in #if canImport(Dispatch) - Add WASMSupport.swift with isWasm flag to disable NetworkObserver tests on WASM - Split SundialLogger fallback into SundialLogger+Fallback.swift to fix one_declaration_per_file lint violation - Enable one_declaration_per_file SwiftLint rule Co-Authored-By: Claude Sonnet 4.6 --- .swift-format | 4 +- .swiftlint.yml | 4 +- .../AsyncStream+Continuation.swift | 2 +- .../ConnectivityObserver+Lifecycle.swift | 2 +- .../ConnectivityObserver+Messaging.swift | 2 +- .../ConnectivityObserver+Streams.swift | 2 +- .../ConnectivityObserver.swift | 2 +- .../SundialKitStream/ConnectivityState.swift | 2 +- .../ConnectivityStateManager+Updates.swift | 2 +- .../ConnectivityStateManager.swift | 2 +- .../SundialKitStream/MessageDispatcher.swift | 2 +- .../SundialKitStream/MessageDistributor.swift | 2 +- .../SundialKitStream/MessageHandling.swift | 2 +- Sources/SundialKitStream/MessageRouter.swift | 2 +- .../NetworkObserver+Init.swift | 2 +- .../SundialKitStream/NetworkObserver.swift | 14 ++- .../StateHandling+MessageHandling.swift | 2 +- Sources/SundialKitStream/StateHandling.swift | 2 +- .../StreamContinuationManager+Messages.swift | 2 +- .../StreamContinuationManager+State.swift | 2 +- .../StreamContinuationManager.swift | 2 +- .../StreamContinuationRegistry.swift | 2 +- .../SundialLogger+Fallback.swift | 112 ++++++++++++++++++ Sources/SundialKitStream/SundialLogger.swift | 89 +------------- .../MockPathMonitor.swift | 58 ++++----- .../NetworkObserverTests.swift | 2 +- Tests/SundialKitStreamTests/WASMSupport.swift | 34 ++++++ 27 files changed, 209 insertions(+), 146 deletions(-) create mode 100644 Sources/SundialKitStream/SundialLogger+Fallback.swift create mode 100644 Tests/SundialKitStreamTests/WASMSupport.swift diff --git a/.swift-format b/.swift-format index 5e103e9..5c31a3e 100644 --- a/.swift-format +++ b/.swift-format @@ -29,7 +29,7 @@ "BeginDocumentationCommentWithOneLineSummary" : false, "DoNotUseSemicolons" : true, "DontRepeatTypeInStaticProperties" : true, - "FileScopedDeclarationPrivacy" : true, + "FileScopedDeclarationPrivacy" : false, "FullyIndirectEnum" : true, "GroupNumericLiterals" : true, "IdentifiersMustBeASCII" : true, @@ -67,4 +67,4 @@ "spacesAroundRangeFormationOperators" : false, "tabWidth" : 2, "version" : 1 -} +} \ No newline at end of file diff --git a/.swiftlint.yml b/.swiftlint.yml index 326f480..a0f98a8 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -52,6 +52,7 @@ opt_in_rules: - nslocalizedstring_require_bundle - number_separator - object_literal + - one_declaration_per_file - operator_usage_whitespace - optional_enum_case_matching - overridden_super_call @@ -115,7 +116,6 @@ excluded: - .build - Mint - Examples - - Sources/MistKit/Generated indentation_width: indentation_width: 2 file_name: @@ -130,4 +130,4 @@ disabled_rules: - trailing_comma - opening_brace - pattern_matching_keywords - - todo \ No newline at end of file + - todo diff --git a/Sources/SundialKitStream/AsyncStream+Continuation.swift b/Sources/SundialKitStream/AsyncStream+Continuation.swift index 45a8ed0..d724ffa 100644 --- a/Sources/SundialKitStream/AsyncStream+Continuation.swift +++ b/Sources/SundialKitStream/AsyncStream+Continuation.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/ConnectivityObserver+Lifecycle.swift b/Sources/SundialKitStream/ConnectivityObserver+Lifecycle.swift index 2941d01..4ac939a 100644 --- a/Sources/SundialKitStream/ConnectivityObserver+Lifecycle.swift +++ b/Sources/SundialKitStream/ConnectivityObserver+Lifecycle.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/ConnectivityObserver+Messaging.swift b/Sources/SundialKitStream/ConnectivityObserver+Messaging.swift index 0eabb10..37f0dde 100644 --- a/Sources/SundialKitStream/ConnectivityObserver+Messaging.swift +++ b/Sources/SundialKitStream/ConnectivityObserver+Messaging.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/ConnectivityObserver+Streams.swift b/Sources/SundialKitStream/ConnectivityObserver+Streams.swift index e1844f7..31dc8f5 100644 --- a/Sources/SundialKitStream/ConnectivityObserver+Streams.swift +++ b/Sources/SundialKitStream/ConnectivityObserver+Streams.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/ConnectivityObserver.swift b/Sources/SundialKitStream/ConnectivityObserver.swift index 8d54393..fbf81c8 100644 --- a/Sources/SundialKitStream/ConnectivityObserver.swift +++ b/Sources/SundialKitStream/ConnectivityObserver.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/ConnectivityState.swift b/Sources/SundialKitStream/ConnectivityState.swift index 14fcbc1..8c6f09f 100644 --- a/Sources/SundialKitStream/ConnectivityState.swift +++ b/Sources/SundialKitStream/ConnectivityState.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/ConnectivityStateManager+Updates.swift b/Sources/SundialKitStream/ConnectivityStateManager+Updates.swift index db3a2ff..cc0fb7d 100644 --- a/Sources/SundialKitStream/ConnectivityStateManager+Updates.swift +++ b/Sources/SundialKitStream/ConnectivityStateManager+Updates.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/ConnectivityStateManager.swift b/Sources/SundialKitStream/ConnectivityStateManager.swift index 3dcae04..09cb98d 100644 --- a/Sources/SundialKitStream/ConnectivityStateManager.swift +++ b/Sources/SundialKitStream/ConnectivityStateManager.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/MessageDispatcher.swift b/Sources/SundialKitStream/MessageDispatcher.swift index 87d17b7..bab9b78 100644 --- a/Sources/SundialKitStream/MessageDispatcher.swift +++ b/Sources/SundialKitStream/MessageDispatcher.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/MessageDistributor.swift b/Sources/SundialKitStream/MessageDistributor.swift index aefa427..e12c209 100644 --- a/Sources/SundialKitStream/MessageDistributor.swift +++ b/Sources/SundialKitStream/MessageDistributor.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/MessageHandling.swift b/Sources/SundialKitStream/MessageHandling.swift index 7ce6065..22a08a8 100644 --- a/Sources/SundialKitStream/MessageHandling.swift +++ b/Sources/SundialKitStream/MessageHandling.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/MessageRouter.swift b/Sources/SundialKitStream/MessageRouter.swift index a58bc79..dcd4628 100644 --- a/Sources/SundialKitStream/MessageRouter.swift +++ b/Sources/SundialKitStream/MessageRouter.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/NetworkObserver+Init.swift b/Sources/SundialKitStream/NetworkObserver+Init.swift index 2680f93..f485a0d 100644 --- a/Sources/SundialKitStream/NetworkObserver+Init.swift +++ b/Sources/SundialKitStream/NetworkObserver+Init.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/NetworkObserver.swift b/Sources/SundialKitStream/NetworkObserver.swift index 41146c3..4586ec7 100644 --- a/Sources/SundialKitStream/NetworkObserver.swift +++ b/Sources/SundialKitStream/NetworkObserver.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -116,11 +116,13 @@ public actor NetworkObserver { // MARK: - Public API - /// Starts monitoring network connectivity - /// - Parameter queue: The dispatch queue for network monitoring - public func start(queue: DispatchQueue) { - monitor.start(queue: queue) - } + #if canImport(Dispatch) + /// Starts monitoring network connectivity + /// - Parameter queue: The dispatch queue for network monitoring + public func start(queue: DispatchQueue) { + monitor.start(queue: queue) + } + #endif /// Cancels network monitoring public func cancel() { diff --git a/Sources/SundialKitStream/StateHandling+MessageHandling.swift b/Sources/SundialKitStream/StateHandling+MessageHandling.swift index 8368f0e..96e2e1e 100644 --- a/Sources/SundialKitStream/StateHandling+MessageHandling.swift +++ b/Sources/SundialKitStream/StateHandling+MessageHandling.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/StateHandling.swift b/Sources/SundialKitStream/StateHandling.swift index 0813ba7..1524dcf 100644 --- a/Sources/SundialKitStream/StateHandling.swift +++ b/Sources/SundialKitStream/StateHandling.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/StreamContinuationManager+Messages.swift b/Sources/SundialKitStream/StreamContinuationManager+Messages.swift index a293e9d..c2103f9 100644 --- a/Sources/SundialKitStream/StreamContinuationManager+Messages.swift +++ b/Sources/SundialKitStream/StreamContinuationManager+Messages.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/StreamContinuationManager+State.swift b/Sources/SundialKitStream/StreamContinuationManager+State.swift index badc3ca..996668a 100644 --- a/Sources/SundialKitStream/StreamContinuationManager+State.swift +++ b/Sources/SundialKitStream/StreamContinuationManager+State.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/StreamContinuationManager.swift b/Sources/SundialKitStream/StreamContinuationManager.swift index a690b0c..214b1a0 100644 --- a/Sources/SundialKitStream/StreamContinuationManager.swift +++ b/Sources/SundialKitStream/StreamContinuationManager.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/StreamContinuationRegistry.swift b/Sources/SundialKitStream/StreamContinuationRegistry.swift index 1f98ae8..e4d2c7f 100644 --- a/Sources/SundialKitStream/StreamContinuationRegistry.swift +++ b/Sources/SundialKitStream/StreamContinuationRegistry.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation diff --git a/Sources/SundialKitStream/SundialLogger+Fallback.swift b/Sources/SundialKitStream/SundialLogger+Fallback.swift new file mode 100644 index 0000000..1409b2b --- /dev/null +++ b/Sources/SundialKitStream/SundialLogger+Fallback.swift @@ -0,0 +1,112 @@ +// +// SundialLogger+Fallback.swift +// SundialKitStream +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if !canImport(os.log) + // MARK: - Fallback for non-Apple platforms (Linux, Windows) + + /// Print-based logging fallback for platforms without OSLog + /// + /// Provides the same API as SundialLogger but uses print() for output + internal enum SundialLogger { + /// Fallback logger that prints to stdout + internal struct FallbackLogger { + internal let subsystem: String + internal let category: String + + internal func error(_ message: String) { + print("[\(subsystem):\(category)] ERROR: \(message)") + } + + internal func info(_ message: String) { + print("[\(subsystem):\(category)] INFO: \(message)") + } + + internal func debug(_ message: String) { + print("[\(subsystem):\(category)] DEBUG: \(message)") + } + } + + /// Core protocols and types + internal static let core = FallbackLogger( + subsystem: "com.brightdigit.SundialKit.Core", + category: "core" + ) + + /// Network monitoring (PathMonitor, NetworkPing) + internal static let network = FallbackLogger( + subsystem: "com.brightdigit.SundialKit.Network", + category: "network" + ) + + /// WatchConnectivity abstractions + internal static let connectivity = FallbackLogger( + subsystem: "com.brightdigit.SundialKit.Connectivity", + category: "connectivity" + ) + + /// Stream-based observers (actor-based AsyncStream APIs) + internal static let stream = FallbackLogger( + subsystem: "com.brightdigit.SundialKit.Stream", + category: "stream" + ) + + /// Combine-based observers (@MainActor with publishers) + internal static let combine = FallbackLogger( + subsystem: "com.brightdigit.SundialKit.Combine", + category: "combine" + ) + + /// Binary message encoding/decoding + internal static let binary = FallbackLogger( + subsystem: "com.brightdigit.SundialKit.Binary", + category: "binary" + ) + + /// Messagable protocol and message decoding + internal static let messagable = FallbackLogger( + subsystem: "com.brightdigit.SundialKit.Messagable", + category: "messagable" + ) + + /// Test infrastructure + internal static let test = FallbackLogger( + subsystem: "com.brightdigit.SundialKit.Tests", + category: "tests" + ) + + /// Create a custom logger for specific categories + /// - Parameters: + /// - subsystem: Reverse DNS notation subsystem identifier + /// - category: Category within the subsystem + /// - Returns: Configured FallbackLogger instance + internal static func custom(subsystem: String, category: String) -> FallbackLogger { + FallbackLogger(subsystem: subsystem, category: category) + } + } +#endif diff --git a/Sources/SundialKitStream/SundialLogger.swift b/Sources/SundialKitStream/SundialLogger.swift index c8a7f7d..42e8d35 100644 --- a/Sources/SundialKitStream/SundialLogger.swift +++ b/Sources/SundialKitStream/SundialLogger.swift @@ -3,7 +3,7 @@ // SundialKitStream // // Created by Leo Dion. -// Copyright © 2025 BrightDigit. +// Copyright © 2026 BrightDigit. // // Permission is hereby granted, free of charge, to any person // obtaining a copy of this software and associated documentation @@ -31,11 +31,7 @@ import Foundation #if canImport(os.log) import os.log -#endif - -// swiftlint:disable file_types_order -#if canImport(os.log) /// Unified logging infrastructure for SundialKit /// /// Provides subsystem-based structured logging using OSLog/Logger framework. @@ -99,87 +95,4 @@ import Foundation Logger(subsystem: subsystem, category: category) } } -#else - // MARK: - Fallback for non-Apple platforms (Linux, Windows) - - /// Print-based logging fallback for platforms without OSLog - /// - /// Provides the same API as SundialLogger but uses print() for output - internal enum SundialLogger { - /// Fallback logger that prints to stdout - internal struct FallbackLogger { - internal let subsystem: String - internal let category: String - - internal func error(_ message: String) { - print("[\(subsystem):\(category)] ERROR: \(message)") - } - - internal func info(_ message: String) { - print("[\(subsystem):\(category)] INFO: \(message)") - } - - internal func debug(_ message: String) { - print("[\(subsystem):\(category)] DEBUG: \(message)") - } - } - - /// Core protocols and types - internal static let core = FallbackLogger( - subsystem: "com.brightdigit.SundialKit.Core", - category: "core" - ) - - /// Network monitoring (PathMonitor, NetworkPing) - internal static let network = FallbackLogger( - subsystem: "com.brightdigit.SundialKit.Network", - category: "network" - ) - - /// WatchConnectivity abstractions - internal static let connectivity = FallbackLogger( - subsystem: "com.brightdigit.SundialKit.Connectivity", - category: "connectivity" - ) - - /// Stream-based observers (actor-based AsyncStream APIs) - internal static let stream = FallbackLogger( - subsystem: "com.brightdigit.SundialKit.Stream", - category: "stream" - ) - - /// Combine-based observers (@MainActor with publishers) - internal static let combine = FallbackLogger( - subsystem: "com.brightdigit.SundialKit.Combine", - category: "combine" - ) - - /// Binary message encoding/decoding - internal static let binary = FallbackLogger( - subsystem: "com.brightdigit.SundialKit.Binary", - category: "binary" - ) - - /// Messagable protocol and message decoding - internal static let messagable = FallbackLogger( - subsystem: "com.brightdigit.SundialKit.Messagable", - category: "messagable" - ) - - /// Test infrastructure - internal static let test = FallbackLogger( - subsystem: "com.brightdigit.SundialKit.Tests", - category: "tests" - ) - - /// Create a custom logger for specific categories - /// - Parameters: - /// - subsystem: Reverse DNS notation subsystem identifier - /// - category: Category within the subsystem - /// - Returns: Configured FallbackLogger instance - internal static func custom(subsystem: String, category: String) -> FallbackLogger { - FallbackLogger(subsystem: subsystem, category: category) - } - } #endif -// swiftlint:enable file_types_order diff --git a/Tests/SundialKitStreamTests/MockPathMonitor.swift b/Tests/SundialKitStreamTests/MockPathMonitor.swift index b0439c3..137e3b0 100644 --- a/Tests/SundialKitStreamTests/MockPathMonitor.swift +++ b/Tests/SundialKitStreamTests/MockPathMonitor.swift @@ -35,39 +35,41 @@ import Foundation // MARK: - Mock Implementations -internal final class MockPathMonitor: PathMonitor, @unchecked Sendable { - internal typealias PathType = MockPath +#if canImport(Dispatch) + internal final class MockPathMonitor: PathMonitor, @unchecked Sendable { + internal typealias PathType = MockPath - internal let id: UUID - internal private(set) var pathUpdate: ((MockPath) -> Void)? - internal private(set) var dispatchQueueLabel: String? - internal private(set) var isCancelled = false + internal let id: UUID + internal private(set) var pathUpdate: ((MockPath) -> Void)? + internal private(set) var dispatchQueueLabel: String? + internal private(set) var isCancelled = false - internal init(id: UUID = UUID()) { - self.id = id - } + internal init(id: UUID = UUID()) { + self.id = id + } - internal func onPathUpdate(_ handler: @escaping (MockPath) -> Void) { - pathUpdate = handler - } + internal func onPathUpdate(_ handler: @escaping (MockPath) -> Void) { + pathUpdate = handler + } - internal func start(queue: DispatchQueue) { - dispatchQueueLabel = queue.label - // Immediately send an initial path - pathUpdate?( - .init( - isConstrained: false, - isExpensive: false, - pathStatus: .satisfied(.wiredEthernet) + internal func start(queue: DispatchQueue) { + dispatchQueueLabel = queue.label + // Immediately send an initial path + pathUpdate?( + .init( + isConstrained: false, + isExpensive: false, + pathStatus: .satisfied(.wiredEthernet) + ) ) - ) - } + } - internal func cancel() { - isCancelled = true - } + internal func cancel() { + isCancelled = true + } - internal func sendPath(_ path: MockPath) { - pathUpdate?(path) + internal func sendPath(_ path: MockPath) { + pathUpdate?(path) + } } -} +#endif diff --git a/Tests/SundialKitStreamTests/NetworkObserverTests.swift b/Tests/SundialKitStreamTests/NetworkObserverTests.swift index b72a232..868f411 100644 --- a/Tests/SundialKitStreamTests/NetworkObserverTests.swift +++ b/Tests/SundialKitStreamTests/NetworkObserverTests.swift @@ -29,5 +29,5 @@ import Testing -@Suite("NetworkObserver Tests") +@Suite("NetworkObserver Tests", .disabled(if: isWasm)) internal enum NetworkObserverTests {} diff --git a/Tests/SundialKitStreamTests/WASMSupport.swift b/Tests/SundialKitStreamTests/WASMSupport.swift new file mode 100644 index 0000000..693ce1f --- /dev/null +++ b/Tests/SundialKitStreamTests/WASMSupport.swift @@ -0,0 +1,34 @@ +// +// WASMSupport.swift +// SundialKitStream +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Dispatch) + internal let isWasm = false +#else + internal let isWasm = true +#endif From 4d78f760c20547c1b2d528e02edb22516a8e4ebe Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Wed, 8 Apr 2026 20:17:22 -0400 Subject: [PATCH 05/10] fix(network): eliminate race condition in derived AsyncStream properties pathStatusStream, isExpensiveStream, and isConstrainedStream were spawning an inner Task to call pathUpdates(), causing a race on slow platforms (watchOS/tvOS) where the continuation was not registered before sendPath() fired, dropping the second value. Replace with direct synchronous continuation registration (matching the pathUpdates() pattern), backed by dedicated continuation dictionaries. Extract internal handlers and remove helpers to NetworkObserver+Handlers.swift to keep the main file within the 225-line lint limit. Co-Authored-By: Claude Sonnet 4.6 --- .../NetworkObserver+Handlers.swift | 87 ++++++ .../NetworkObserver+Init.swift | 33 +- .../SundialKitStream/NetworkObserver.swift | 295 +++++++++--------- 3 files changed, 251 insertions(+), 164 deletions(-) create mode 100644 Sources/SundialKitStream/NetworkObserver+Handlers.swift diff --git a/Sources/SundialKitStream/NetworkObserver+Handlers.swift b/Sources/SundialKitStream/NetworkObserver+Handlers.swift new file mode 100644 index 0000000..a1f6453 --- /dev/null +++ b/Sources/SundialKitStream/NetworkObserver+Handlers.swift @@ -0,0 +1,87 @@ +// +// NetworkObserver+Handlers.swift +// SundialKitStream +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Network) + import Foundation + import SundialKitNetwork + + @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) + extension NetworkObserver { + // MARK: - Internal Handlers + + internal func handlePathUpdate(_ path: MonitorType.PathType) { + currentPath = path + + // Notify all active path stream subscribers + for continuation in pathContinuations.values { + continuation.yield(path) + } + for continuation in pathStatusContinuations.values { + continuation.yield(path.pathStatus) + } + for continuation in isExpensiveContinuations.values { + continuation.yield(path.isExpensive) + } + for continuation in isConstrainedContinuations.values { + continuation.yield(path.isConstrained) + } + } + + internal func handlePingStatusUpdate(_ status: PingType.StatusType) { + currentPingStatus = status + + // Notify all active ping status stream subscribers + for continuation in pingStatusContinuations.values { + continuation.yield(status) + } + } + + // MARK: - Continuation Removal + + internal func removePathContinuation(id: UUID) { + pathContinuations.removeValue(forKey: id) + } + + internal func removePathStatusContinuation(id: UUID) { + pathStatusContinuations.removeValue(forKey: id) + } + + internal func removeIsExpensiveContinuation(id: UUID) { + isExpensiveContinuations.removeValue(forKey: id) + } + + internal func removeIsConstrainedContinuation(id: UUID) { + isConstrainedContinuations.removeValue(forKey: id) + } + + internal func removePingStatusContinuation(id: UUID) { + pingStatusContinuations.removeValue(forKey: id) + } + } +#endif diff --git a/Sources/SundialKitStream/NetworkObserver+Init.swift b/Sources/SundialKitStream/NetworkObserver+Init.swift index f485a0d..408bf9d 100644 --- a/Sources/SundialKitStream/NetworkObserver+Init.swift +++ b/Sources/SundialKitStream/NetworkObserver+Init.swift @@ -27,27 +27,26 @@ // OTHER DEALINGS IN THE SOFTWARE. // -public import SundialKitNetwork +#if canImport(Network) + public import Network + public import SundialKitNetwork -// MARK: - Convenience Initializers -@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) -extension NetworkObserver where PingType == NeverPing { - /// Creates `NetworkObserver` without ping - public init(monitor: MonitorType) { - self.init(monitor: monitor, pingOrNil: nil) + // MARK: - Convenience Initializers + @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) + extension NetworkObserver where PingType == NeverPing { + /// Creates `NetworkObserver` without ping + public init(monitor: MonitorType) { + self.init(monitor: monitor, pingOrNil: nil) + } } -} -@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) -extension NetworkObserver { - /// Creates `NetworkObserver` with ping - public init(monitor: MonitorType, ping: PingType) { - self.init(monitor: monitor, pingOrNil: ping) + @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) + extension NetworkObserver { + /// Creates `NetworkObserver` with ping + public init(monitor: MonitorType, ping: PingType) { + self.init(monitor: monitor, pingOrNil: ping) + } } -} - -#if canImport(Network) - public import Network @available(macOS 13, iOS 16, watchOS 9, tvOS 16, *) extension NetworkObserver where MonitorType == NWPathMonitor, PingType == NeverPing { diff --git a/Sources/SundialKitStream/NetworkObserver.swift b/Sources/SundialKitStream/NetworkObserver.swift index 4586ec7..2730e73 100644 --- a/Sources/SundialKitStream/NetworkObserver.swift +++ b/Sources/SundialKitStream/NetworkObserver.swift @@ -29,187 +29,188 @@ public import Foundation public import SundialKitCore -public import SundialKitNetwork - -/// Actor-based network connectivity observer providing AsyncStream APIs -/// -/// `NetworkObserver` monitors network connectivity status using Swift concurrency. -/// This is the modern async/await replacement for the Combine-based observer. -/// -/// ## Example Usage -/// -/// ```swift -/// import SundialKitStream -/// import SundialKitNetwork -/// -/// let observer = NetworkObserver( -/// monitor: NWPathMonitorAdapter(), -/// ping: nil -/// ) -/// -/// await observer.start(queue: .global()) -/// -/// // AsyncSequence API -/// for await status in observer.pathStatusStream { -/// print("Network status: \(status)") -/// } -/// ``` -/// -@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) -public actor NetworkObserver { - // MARK: - Private Properties - - private let ping: PingType? - private let monitor: MonitorType - private var currentPath: MonitorType.PathType? - private var currentPingStatus: PingType.StatusType? - private var pathContinuations: [UUID: AsyncStream.Continuation] = [:] - private var pingStatusContinuations: [UUID: AsyncStream.Continuation] = [:] - - /// Stream of path status changes - public var pathStatusStream: AsyncStream { - AsyncStream { continuation in - Task { - for await path in pathUpdates() { - continuation.yield(path.pathStatus) + +#if canImport(Network) + public import SundialKitNetwork + + /// Actor-based network connectivity observer providing AsyncStream APIs + /// + /// `NetworkObserver` monitors network connectivity status using Swift concurrency. + /// This is the modern async/await replacement for the Combine-based observer. + /// + /// ## Example Usage + /// + /// ```swift + /// import SundialKitStream + /// import SundialKitNetwork + /// + /// let observer = NetworkObserver( + /// monitor: NWPathMonitorAdapter(), + /// ping: nil + /// ) + /// + /// await observer.start(queue: .global()) + /// + /// // AsyncSequence API + /// for await status in observer.pathStatusStream { + /// print("Network status: \(status)") + /// } + /// ``` + /// + @available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *) + public actor NetworkObserver { + // MARK: - Internal Properties + + internal let ping: PingType? + internal let monitor: MonitorType + internal var currentPath: MonitorType.PathType? + internal var currentPingStatus: PingType.StatusType? + internal var pathContinuations: [UUID: AsyncStream.Continuation] = [:] + internal var pathStatusContinuations: [UUID: AsyncStream.Continuation] = [:] + internal var isExpensiveContinuations: [UUID: AsyncStream.Continuation] = [:] + internal var isConstrainedContinuations: [UUID: AsyncStream.Continuation] = [:] + internal var pingStatusContinuations: [UUID: AsyncStream.Continuation] = + [:] + + /// Stream of path status changes + public var pathStatusStream: AsyncStream { + AsyncStream(bufferingPolicy: .unbounded) { continuation in + let id = UUID() + pathStatusContinuations[id] = continuation + if let currentPath { + continuation.yield(currentPath.pathStatus) + } + continuation.onTermination = { [weak self] _ in + Task { await self?.removePathStatusContinuation(id: id) } } - continuation.finish() } } - } - /// Stream of expensive state changes - public var isExpensiveStream: AsyncStream { - AsyncStream { continuation in - Task { - for await path in pathUpdates() { - continuation.yield(path.isExpensive) + /// Stream of expensive state changes + public var isExpensiveStream: AsyncStream { + AsyncStream(bufferingPolicy: .unbounded) { continuation in + let id = UUID() + isExpensiveContinuations[id] = continuation + if let currentPath { + continuation.yield(currentPath.isExpensive) + } + continuation.onTermination = { [weak self] _ in + Task { await self?.removeIsExpensiveContinuation(id: id) } } - continuation.finish() } } - } - /// Stream of constrained state changes - public var isConstrainedStream: AsyncStream { - AsyncStream { continuation in - Task { - for await path in pathUpdates() { - continuation.yield(path.isConstrained) + /// Stream of constrained state changes + public var isConstrainedStream: AsyncStream { + AsyncStream(bufferingPolicy: .unbounded) { continuation in + let id = UUID() + isConstrainedContinuations[id] = continuation + if let currentPath { + continuation.yield(currentPath.isConstrained) + } + continuation.onTermination = { [weak self] _ in + Task { await self?.removeIsConstrainedContinuation(id: id) } } - continuation.finish() } } - } - - // MARK: - Initialization - - internal init(monitor: MonitorType, pingOrNil: PingType?) { - self.monitor = monitor - self.ping = pingOrNil - - // Setup callback from monitor - monitor.onPathUpdate { [weak self] path in - Task { await self?.handlePathUpdate(path) } - } - } - - // MARK: - Public API - - #if canImport(Dispatch) - /// Starts monitoring network connectivity - /// - Parameter queue: The dispatch queue for network monitoring - public func start(queue: DispatchQueue) { - monitor.start(queue: queue) - } - #endif - /// Cancels network monitoring - public func cancel() { - monitor.cancel() + // MARK: - Initialization - // Finish all active streams - for continuation in pathContinuations.values { - continuation.finish() - } - pathContinuations.removeAll() + internal init(monitor: MonitorType, pingOrNil: PingType?) { + self.monitor = monitor + self.ping = pingOrNil - for continuation in pingStatusContinuations.values { - continuation.finish() + // Setup callback from monitor + monitor.onPathUpdate { [weak self] path in + Task { await self?.handlePathUpdate(path) } + } } - pingStatusContinuations.removeAll() - } - /// Current network path snapshot - public func getCurrentPath() -> MonitorType.PathType? { - currentPath - } - - /// Current ping status snapshot - public func getCurrentPingStatus() -> PingType.StatusType? { - currentPingStatus - } + // MARK: - Public API - // MARK: - AsyncStream APIs + #if canImport(Dispatch) + /// Starts monitoring network connectivity + /// - Parameter queue: The dispatch queue for network monitoring + public func start(queue: DispatchQueue) { + monitor.start(queue: queue) + } + #endif - /// Stream of path updates - public func pathUpdates() -> AsyncStream { - AsyncStream { continuation in - let id = UUID() - pathContinuations[id] = continuation + /// Cancels network monitoring + public func cancel() { + monitor.cancel() - // Send current value immediately if available - if let currentPath = currentPath { - continuation.yield(currentPath) + // Finish all active streams + for continuation in pathContinuations.values { + continuation.finish() } + pathContinuations.removeAll() - continuation.onTermination = { [weak self] _ in - Task { await self?.removePathContinuation(id: id) } + for continuation in pathStatusContinuations.values { + continuation.finish() } - } - } + pathStatusContinuations.removeAll() - /// Stream of ping status updates - public func pingStatusUpdates() -> AsyncStream { - AsyncStream { continuation in - let id = UUID() - pingStatusContinuations[id] = continuation + for continuation in isExpensiveContinuations.values { + continuation.finish() + } + isExpensiveContinuations.removeAll() - // Send current value immediately if available - if let currentPingStatus = currentPingStatus { - continuation.yield(currentPingStatus) + for continuation in isConstrainedContinuations.values { + continuation.finish() } + isConstrainedContinuations.removeAll() - continuation.onTermination = { [weak self] _ in - Task { await self?.removePingStatusContinuation(id: id) } + for continuation in pingStatusContinuations.values { + continuation.finish() } + pingStatusContinuations.removeAll() } - } - // MARK: - Internal Handlers - private func handlePathUpdate(_ path: MonitorType.PathType) { - currentPath = path + /// Current network path snapshot + public func getCurrentPath() -> MonitorType.PathType? { + currentPath + } - // Notify all active path stream subscribers - for continuation in pathContinuations.values { - continuation.yield(path) + /// Current ping status snapshot + public func getCurrentPingStatus() -> PingType.StatusType? { + currentPingStatus } - } - private func handlePingStatusUpdate(_ status: PingType.StatusType) { - currentPingStatus = status + // MARK: - AsyncStream APIs - // Notify all active ping status stream subscribers - for continuation in pingStatusContinuations.values { - continuation.yield(status) + /// Stream of path updates + public func pathUpdates() -> AsyncStream { + AsyncStream(bufferingPolicy: .unbounded) { continuation in + let id = UUID() + pathContinuations[id] = continuation + + // Send current value immediately if available + if let currentPath = currentPath { + continuation.yield(currentPath) + } + + continuation.onTermination = { [weak self] _ in + Task { await self?.removePathContinuation(id: id) } + } + } } - } - private func removePathContinuation(id: UUID) { - pathContinuations.removeValue(forKey: id) - } + /// Stream of ping status updates + public func pingStatusUpdates() -> AsyncStream { + AsyncStream(bufferingPolicy: .unbounded) { continuation in + let id = UUID() + pingStatusContinuations[id] = continuation + + // Send current value immediately if available + if let currentPingStatus = currentPingStatus { + continuation.yield(currentPingStatus) + } - private func removePingStatusContinuation(id: UUID) { - pingStatusContinuations.removeValue(forKey: id) + continuation.onTermination = { [weak self] _ in + Task { await self?.removePingStatusContinuation(id: id) } + } + } + } } -} +#endif From a4ae6d4f51ceda4ee0221000fca066df32572398 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 9 Apr 2026 00:36:32 +0000 Subject: [PATCH 06/10] fix(tests): guard NetworkObserver tests with canImport(Network) for non-Apple platforms The recent commit wrapped NetworkObserver source in #if canImport(Network), but tests still imported SundialKitNetwork unconditionally, breaking swift test on Linux/WASM. Add canImport(Network) guards to all network test and mock files, and add hasNetwork trait to disable the suite at runtime. Co-Authored-By: Claude Opus 4.6 (1M context) --- .devcontainer/devcontainer.json | 6 +- .devcontainer/swift-6.1/devcontainer.json | 2 +- .../swift-6.2-nightly/devcontainer.json | 40 ---------- .devcontainer/swift-6.2/devcontainer.json | 2 +- .../devcontainer.json | 8 +- .../MockNetworkPing.swift | 80 ++++++++++--------- Tests/SundialKitStreamTests/MockPath.swift | 34 ++++---- .../MockPathMonitor.swift | 12 +-- .../NetworkObserverTests+EdgeCases.swift | 16 ++-- .../NetworkObserverTests+Initialization.swift | 16 ++-- .../NetworkObserverTests+Stream.swift | 16 ++-- .../NetworkObserverTests.swift | 2 +- Tests/SundialKitStreamTests/WASMSupport.swift | 6 ++ 13 files changed, 108 insertions(+), 132 deletions(-) delete mode 100644 .devcontainer/swift-6.2-nightly/devcontainer.json rename .devcontainer/{swift-6.1-nightly => swift-6.3}/devcontainer.json (85%) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6fed9ba..633ecfe 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { - "name": "Swift 6.2", - "image": "swift:6.2", + "name": "Swift 6.3", + "image": "swift:6.3", "features": { "ghcr.io/devcontainers/features/common-utils:2": { "installZsh": "false", @@ -24,7 +24,7 @@ "lldb.library": "/usr/lib/liblldb.so" }, "extensions": [ - "sswg.swift-lang" + "swift-server.swift" ] } }, diff --git a/.devcontainer/swift-6.1/devcontainer.json b/.devcontainer/swift-6.1/devcontainer.json index bdb65e1..23821a4 100644 --- a/.devcontainer/swift-6.1/devcontainer.json +++ b/.devcontainer/swift-6.1/devcontainer.json @@ -24,7 +24,7 @@ "lldb.library": "/usr/lib/liblldb.so" }, "extensions": [ - "sswg.swift-lang" + "swift-server.swift" ] } }, diff --git a/.devcontainer/swift-6.2-nightly/devcontainer.json b/.devcontainer/swift-6.2-nightly/devcontainer.json deleted file mode 100644 index b5bd73c..0000000 --- a/.devcontainer/swift-6.2-nightly/devcontainer.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "name": "Swift 6.2 Nightly", - "image": "swiftlang/swift:nightly-6.2-noble", - "features": { - "ghcr.io/devcontainers/features/common-utils:2": { - "installZsh": "false", - "username": "vscode", - "upgradePackages": "false" - }, - "ghcr.io/devcontainers/features/git:1": { - "version": "os-provided", - "ppa": "false" - } - }, - "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", - "runArgs": [ - "--cap-add=SYS_PTRACE", - "--security-opt", - "seccomp=unconfined" - ], - // Configure tool-specific properties. - "customizations": { - // Configure properties specific to VS Code. - "vscode": { - // Set *default* container specific settings.json values on container create. - "settings": { - "lldb.library": "/usr/lib/liblldb.so" - }, - // Add the IDs of extensions you want installed when the container is created. - "extensions": [ - "sswg.swift-lang" - ] - } - }, - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // "forwardPorts": [], - - // Set `remoteUser` to `root` to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. - "remoteUser": "root" -} \ No newline at end of file diff --git a/.devcontainer/swift-6.2/devcontainer.json b/.devcontainer/swift-6.2/devcontainer.json index 6fed9ba..f04399b 100644 --- a/.devcontainer/swift-6.2/devcontainer.json +++ b/.devcontainer/swift-6.2/devcontainer.json @@ -24,7 +24,7 @@ "lldb.library": "/usr/lib/liblldb.so" }, "extensions": [ - "sswg.swift-lang" + "swift-server.swift" ] } }, diff --git a/.devcontainer/swift-6.1-nightly/devcontainer.json b/.devcontainer/swift-6.3/devcontainer.json similarity index 85% rename from .devcontainer/swift-6.1-nightly/devcontainer.json rename to .devcontainer/swift-6.3/devcontainer.json index 7949dc9..80941c7 100644 --- a/.devcontainer/swift-6.1-nightly/devcontainer.json +++ b/.devcontainer/swift-6.3/devcontainer.json @@ -1,6 +1,6 @@ { - "name": "Swift 6.1 Nightly", - "image": "swiftlang/swift:nightly-6.1-noble", + "name": "Swift 6.3", + "image": "swift:6.3", "features": { "ghcr.io/devcontainers/features/common-utils:2": { "installZsh": "false", @@ -24,9 +24,9 @@ "lldb.library": "/usr/lib/liblldb.so" }, "extensions": [ - "sswg.swift-lang" + "swift-server.swift" ] } }, "remoteUser": "root" -} \ No newline at end of file +} diff --git a/Tests/SundialKitStreamTests/MockNetworkPing.swift b/Tests/SundialKitStreamTests/MockNetworkPing.swift index a863eb6..39058aa 100644 --- a/Tests/SundialKitStreamTests/MockNetworkPing.swift +++ b/Tests/SundialKitStreamTests/MockNetworkPing.swift @@ -5,43 +5,45 @@ // Created by Leo Dion on 11/14/25. // -import Foundation - -@testable import SundialKitCore -@testable import SundialKitNetwork -@testable import SundialKitStream - -internal final class MockNetworkPing: NetworkPing, @unchecked Sendable { - internal struct StatusType: Sendable, Equatable { - internal let value: String - } - - internal private(set) var lastShouldPingStatus: PathStatus? - internal let id: UUID - internal let timeInterval: TimeInterval - internal var shouldPingResponse: Bool - internal var onPingHandler: ((StatusType) -> Void)? - - internal init( - id: UUID = UUID(), - timeInterval: TimeInterval = 1.0, - shouldPingResponse: Bool = true - ) { - self.id = id - self.timeInterval = timeInterval - self.shouldPingResponse = shouldPingResponse - } - - internal func shouldPing(onStatus status: PathStatus) -> Bool { - lastShouldPingStatus = status - return shouldPingResponse - } - - internal func onPing(_ closure: @escaping (StatusType) -> Void) { - onPingHandler = closure - } - - internal func sendPingStatus(_ status: StatusType) { - onPingHandler?(status) +#if canImport(Network) + import Foundation + + @testable import SundialKitCore + @testable import SundialKitNetwork + @testable import SundialKitStream + + internal final class MockNetworkPing: NetworkPing, @unchecked Sendable { + internal struct StatusType: Sendable, Equatable { + internal let value: String + } + + internal private(set) var lastShouldPingStatus: PathStatus? + internal let id: UUID + internal let timeInterval: TimeInterval + internal var shouldPingResponse: Bool + internal var onPingHandler: ((StatusType) -> Void)? + + internal init( + id: UUID = UUID(), + timeInterval: TimeInterval = 1.0, + shouldPingResponse: Bool = true + ) { + self.id = id + self.timeInterval = timeInterval + self.shouldPingResponse = shouldPingResponse + } + + internal func shouldPing(onStatus status: PathStatus) -> Bool { + lastShouldPingStatus = status + return shouldPingResponse + } + + internal func onPing(_ closure: @escaping (StatusType) -> Void) { + onPingHandler = closure + } + + internal func sendPingStatus(_ status: StatusType) { + onPingHandler?(status) + } } -} +#endif diff --git a/Tests/SundialKitStreamTests/MockPath.swift b/Tests/SundialKitStreamTests/MockPath.swift index d7d2c01..a282ea4 100644 --- a/Tests/SundialKitStreamTests/MockPath.swift +++ b/Tests/SundialKitStreamTests/MockPath.swift @@ -5,22 +5,24 @@ // Created by Leo Dion on 11/14/25. // -@testable import SundialKitCore -@testable import SundialKitNetwork -@testable import SundialKitStream +#if canImport(Network) + @testable import SundialKitCore + @testable import SundialKitNetwork + @testable import SundialKitStream -internal struct MockPath: NetworkPath { - internal let isConstrained: Bool - internal let isExpensive: Bool - internal let pathStatus: PathStatus + internal struct MockPath: NetworkPath { + internal let isConstrained: Bool + internal let isExpensive: Bool + internal let pathStatus: PathStatus - internal init( - isConstrained: Bool = false, - isExpensive: Bool = false, - pathStatus: PathStatus = .unknown - ) { - self.isConstrained = isConstrained - self.isExpensive = isExpensive - self.pathStatus = pathStatus + internal init( + isConstrained: Bool = false, + isExpensive: Bool = false, + pathStatus: PathStatus = .unknown + ) { + self.isConstrained = isConstrained + self.isExpensive = isExpensive + self.pathStatus = pathStatus + } } -} +#endif diff --git a/Tests/SundialKitStreamTests/MockPathMonitor.swift b/Tests/SundialKitStreamTests/MockPathMonitor.swift index 137e3b0..c7390c0 100644 --- a/Tests/SundialKitStreamTests/MockPathMonitor.swift +++ b/Tests/SundialKitStreamTests/MockPathMonitor.swift @@ -27,15 +27,15 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation +#if canImport(Network) && canImport(Dispatch) + import Foundation -@testable import SundialKitCore -@testable import SundialKitNetwork -@testable import SundialKitStream + @testable import SundialKitCore + @testable import SundialKitNetwork + @testable import SundialKitStream -// MARK: - Mock Implementations + // MARK: - Mock Implementations -#if canImport(Dispatch) internal final class MockPathMonitor: PathMonitor, @unchecked Sendable { internal typealias PathType = MockPath diff --git a/Tests/SundialKitStreamTests/NetworkObserverTests+EdgeCases.swift b/Tests/SundialKitStreamTests/NetworkObserverTests+EdgeCases.swift index 6e4d475..86386cf 100644 --- a/Tests/SundialKitStreamTests/NetworkObserverTests+EdgeCases.swift +++ b/Tests/SundialKitStreamTests/NetworkObserverTests+EdgeCases.swift @@ -27,14 +27,15 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +#if canImport(Network) + import Foundation + import Testing -@testable import SundialKitCore -@testable import SundialKitNetwork -@testable import SundialKitStream + @testable import SundialKitCore + @testable import SundialKitNetwork + @testable import SundialKitStream -extension NetworkObserverTests { + extension NetworkObserverTests { @Suite("Edge Cases and State Tests") internal struct EdgeCasesTests { // MARK: - Current State Tests @@ -140,4 +141,5 @@ extension NetworkObserverTests { #expect(receivedAfterCancel != true) } } -} + } +#endif diff --git a/Tests/SundialKitStreamTests/NetworkObserverTests+Initialization.swift b/Tests/SundialKitStreamTests/NetworkObserverTests+Initialization.swift index d6ff4bb..897e171 100644 --- a/Tests/SundialKitStreamTests/NetworkObserverTests+Initialization.swift +++ b/Tests/SundialKitStreamTests/NetworkObserverTests+Initialization.swift @@ -27,14 +27,15 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +#if canImport(Network) + import Foundation + import Testing -@testable import SundialKitCore -@testable import SundialKitNetwork -@testable import SundialKitStream + @testable import SundialKitCore + @testable import SundialKitNetwork + @testable import SundialKitStream -extension NetworkObserverTests { + extension NetworkObserverTests { @Suite("Initialization and Lifecycle Tests") internal struct InitializationTests { // MARK: - Initialization Tests @@ -138,4 +139,5 @@ extension NetworkObserverTests { // Note: Full ping integration testing would require NetworkMonitor-level tests // since NetworkObserver doesn't directly manage ping lifecycle } -} + } +#endif diff --git a/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift b/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift index 3298962..72e021c 100644 --- a/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift +++ b/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift @@ -27,14 +27,15 @@ // OTHER DEALINGS IN THE SOFTWARE. // -import Foundation -import Testing +#if canImport(Network) + import Foundation + import Testing -@testable import SundialKitCore -@testable import SundialKitNetwork -@testable import SundialKitStream + @testable import SundialKitCore + @testable import SundialKitNetwork + @testable import SundialKitStream -extension NetworkObserverTests { + extension NetworkObserverTests { @Suite("Stream Tests") internal struct StreamTests { // MARK: - Path Updates Stream Tests @@ -217,4 +218,5 @@ extension NetworkObserverTests { #expect(path2Second?.pathStatus == .satisfied(.cellular)) } } -} + } +#endif diff --git a/Tests/SundialKitStreamTests/NetworkObserverTests.swift b/Tests/SundialKitStreamTests/NetworkObserverTests.swift index 868f411..4ba9b66 100644 --- a/Tests/SundialKitStreamTests/NetworkObserverTests.swift +++ b/Tests/SundialKitStreamTests/NetworkObserverTests.swift @@ -29,5 +29,5 @@ import Testing -@Suite("NetworkObserver Tests", .disabled(if: isWasm)) +@Suite("NetworkObserver Tests", .disabled(if: isWasm || !hasNetwork)) internal enum NetworkObserverTests {} diff --git a/Tests/SundialKitStreamTests/WASMSupport.swift b/Tests/SundialKitStreamTests/WASMSupport.swift index 693ce1f..f24dc99 100644 --- a/Tests/SundialKitStreamTests/WASMSupport.swift +++ b/Tests/SundialKitStreamTests/WASMSupport.swift @@ -32,3 +32,9 @@ #else internal let isWasm = true #endif + +#if canImport(Network) + internal let hasNetwork = true +#else + internal let hasNetwork = false +#endif From 650620e0f1a12bfab8924785bdfd4f482837e599 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Thu, 9 Apr 2026 00:56:39 +0000 Subject: [PATCH 07/10] refactor(tests): replace isWasm/hasNetwork booleans with SupportedModule OptionSet Use .enabled(if:) with SupportedModule instead of .disabled(if:) with legacy boolean constants for clearer test suite conditional execution. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../NetworkObserverTests+EdgeCases.swift | 158 +++++----- .../NetworkObserverTests+Initialization.swift | 154 +++++----- .../NetworkObserverTests+Stream.swift | 276 +++++++++--------- .../NetworkObserverTests.swift | 5 +- ...ASMSupport.swift => SupportedModule.swift} | 34 ++- 5 files changed, 322 insertions(+), 305 deletions(-) rename Tests/SundialKitStreamTests/{WASMSupport.swift => SupportedModule.swift} (67%) diff --git a/Tests/SundialKitStreamTests/NetworkObserverTests+EdgeCases.swift b/Tests/SundialKitStreamTests/NetworkObserverTests+EdgeCases.swift index 86386cf..e664071 100644 --- a/Tests/SundialKitStreamTests/NetworkObserverTests+EdgeCases.swift +++ b/Tests/SundialKitStreamTests/NetworkObserverTests+EdgeCases.swift @@ -36,110 +36,110 @@ @testable import SundialKitStream extension NetworkObserverTests { - @Suite("Edge Cases and State Tests") - internal struct EdgeCasesTests { - // MARK: - Current State Tests + @Suite("Edge Cases and State Tests") + internal struct EdgeCasesTests { + // MARK: - Current State Tests - @Test("getCurrentPath returns latest path") - internal func getCurrentPathSnapshot() async { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("getCurrentPath returns latest path") + internal func getCurrentPathSnapshot() async { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - // Before start - var currentPath = await observer.getCurrentPath() - #expect(currentPath == nil) + // Before start + var currentPath = await observer.getCurrentPath() + #expect(currentPath == nil) - // After start - await observer.start(queue: .global()) + // After start + await observer.start(queue: .global()) - // Give time for async path update from start() - try? await Task.sleep(for: .milliseconds(10)) + // Give time for async path update from start() + try? await Task.sleep(for: .milliseconds(10)) - currentPath = await observer.getCurrentPath() - #expect(currentPath?.pathStatus == .satisfied(.wiredEthernet)) + currentPath = await observer.getCurrentPath() + #expect(currentPath?.pathStatus == .satisfied(.wiredEthernet)) - // After update - let newPath = MockPath(pathStatus: .satisfied(.wifi)) - monitor.sendPath(newPath) + // After update + let newPath = MockPath(pathStatus: .satisfied(.wifi)) + monitor.sendPath(newPath) - // Give time for async update - try? await Task.sleep(for: .milliseconds(10)) + // Give time for async update + try? await Task.sleep(for: .milliseconds(10)) - currentPath = await observer.getCurrentPath() - #expect(currentPath?.pathStatus == .satisfied(.wifi)) - } + currentPath = await observer.getCurrentPath() + #expect(currentPath?.pathStatus == .satisfied(.wifi)) + } - @Test("getCurrentPingStatus returns nil when no ping configured") - internal func getCurrentPingStatusWithoutPing() async { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("getCurrentPingStatus returns nil when no ping configured") + internal func getCurrentPingStatusWithoutPing() async { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - await observer.start(queue: .global()) + await observer.start(queue: .global()) - let pingStatus = await observer.getCurrentPingStatus() - #expect(pingStatus == nil) - } + let pingStatus = await observer.getCurrentPingStatus() + #expect(pingStatus == nil) + } - // MARK: - Stream Cleanup Tests + // MARK: - Stream Cleanup Tests - @Test("Cancel finishes all active path streams") - internal func cancelFinishesPathStreams() async throws { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("Cancel finishes all active path streams") + internal func cancelFinishesPathStreams() async throws { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - await observer.start(queue: .global()) + await observer.start(queue: .global()) - let stream = await observer.pathUpdates() - var iterator = stream.makeAsyncIterator() + let stream = await observer.pathUpdates() + var iterator = stream.makeAsyncIterator() - // Get first value - _ = await iterator.next() + // Get first value + _ = await iterator.next() - // Cancel observer - await observer.cancel() + // Cancel observer + await observer.cancel() - // Try to get next value - should complete - let nextValue = await iterator.next() - #expect(nextValue == nil) - } + // Try to get next value - should complete + let nextValue = await iterator.next() + #expect(nextValue == nil) + } - @Test("Stream iteration completes after cancel") - internal func streamCompletesAfterCancel() async throws { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) - - await observer.start(queue: .global()) - - let capture = TestValueCapture() - - try await confirmation("Received initial status", expectedCount: 1) { confirm in - Task { @Sendable in - let stream = await observer.pathStatusStream - var count = 0 - for await _ in stream { - count += 1 - if count == 1 { - confirm() - } else { - // Should not receive values after cancel - await capture.set(boolValue: true) + @Test("Stream iteration completes after cancel") + internal func streamCompletesAfterCancel() async throws { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) + + await observer.start(queue: .global()) + + let capture = TestValueCapture() + + try await confirmation("Received initial status", expectedCount: 1) { confirm in + Task { @Sendable in + let stream = await observer.pathStatusStream + var count = 0 + for await _ in stream { + count += 1 + if count == 1 { + confirm() + } else { + // Should not receive values after cancel + await capture.set(boolValue: true) + } } } - } - // Wait for initial value confirmation - try await Task.sleep(for: .milliseconds(50)) + // Wait for initial value confirmation + try await Task.sleep(for: .milliseconds(50)) - // Cancel observer - await observer.cancel() + // Cancel observer + await observer.cancel() - // Give time to verify no additional values are received - try await Task.sleep(for: .milliseconds(100)) - } + // Give time to verify no additional values are received + try await Task.sleep(for: .milliseconds(100)) + } - let receivedAfterCancel = await capture.boolValue - #expect(receivedAfterCancel != true) + let receivedAfterCancel = await capture.boolValue + #expect(receivedAfterCancel != true) + } } } - } #endif diff --git a/Tests/SundialKitStreamTests/NetworkObserverTests+Initialization.swift b/Tests/SundialKitStreamTests/NetworkObserverTests+Initialization.swift index 897e171..826123f 100644 --- a/Tests/SundialKitStreamTests/NetworkObserverTests+Initialization.swift +++ b/Tests/SundialKitStreamTests/NetworkObserverTests+Initialization.swift @@ -36,108 +36,108 @@ @testable import SundialKitStream extension NetworkObserverTests { - @Suite("Initialization and Lifecycle Tests") - internal struct InitializationTests { - // MARK: - Initialization Tests + @Suite("Initialization and Lifecycle Tests") + internal struct InitializationTests { + // MARK: - Initialization Tests - @Test("NetworkObserver initializes with monitor only") - internal func initializationWithMonitorOnly() async { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("NetworkObserver initializes with monitor only") + internal func initializationWithMonitorOnly() async { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - let currentPath = await observer.getCurrentPath() - let currentPingStatus = await observer.getCurrentPingStatus() + let currentPath = await observer.getCurrentPath() + let currentPingStatus = await observer.getCurrentPingStatus() - #expect(currentPath == nil) - #expect(currentPingStatus == nil) - } + #expect(currentPath == nil) + #expect(currentPingStatus == nil) + } - @Test("NetworkObserver initializes with monitor and ping") - internal func initializationWithMonitorAndPing() async { - let monitor = MockPathMonitor() - let ping = MockNetworkPing() - let observer = NetworkObserver(monitor: monitor, ping: ping) + @Test("NetworkObserver initializes with monitor and ping") + internal func initializationWithMonitorAndPing() async { + let monitor = MockPathMonitor() + let ping = MockNetworkPing() + let observer = NetworkObserver(monitor: monitor, ping: ping) - let currentPath = await observer.getCurrentPath() - let currentPingStatus = await observer.getCurrentPingStatus() + let currentPath = await observer.getCurrentPath() + let currentPingStatus = await observer.getCurrentPingStatus() - #expect(currentPath == nil) - #expect(currentPingStatus == nil) - } + #expect(currentPath == nil) + #expect(currentPingStatus == nil) + } - // MARK: - Start/Cancel Tests + // MARK: - Start/Cancel Tests - @Test("Start monitoring begins path updates") - internal func startMonitoring() async { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("Start monitoring begins path updates") + internal func startMonitoring() async { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - await observer.start(queue: .global()) + await observer.start(queue: .global()) - #expect(monitor.dispatchQueueLabel != nil) - #expect(monitor.isCancelled == false) + #expect(monitor.dispatchQueueLabel != nil) + #expect(monitor.isCancelled == false) - // Give time for async path update from start() - try? await Task.sleep(for: .milliseconds(10)) + // Give time for async path update from start() + try? await Task.sleep(for: .milliseconds(10)) - // Should receive initial path from start() - let currentPath = await observer.getCurrentPath() - #expect(currentPath != nil) - #expect(currentPath?.pathStatus == .satisfied(.wiredEthernet)) - } + // Should receive initial path from start() + let currentPath = await observer.getCurrentPath() + #expect(currentPath != nil) + #expect(currentPath?.pathStatus == .satisfied(.wiredEthernet)) + } - @Test("Cancel stops monitoring and finishes streams") - internal func cancelMonitoring() async { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("Cancel stops monitoring and finishes streams") + internal func cancelMonitoring() async { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - await observer.start(queue: .global()) - await observer.cancel() + await observer.start(queue: .global()) + await observer.cancel() - #expect(monitor.isCancelled == true) - } + #expect(monitor.isCancelled == true) + } - @Test("Path updates before start are not tracked") - internal func pathUpdatesBeforeStart() async { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("Path updates before start are not tracked") + internal func pathUpdatesBeforeStart() async { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - // Don't call start() - let currentPath = await observer.getCurrentPath() - #expect(currentPath == nil) - } + // Don't call start() + let currentPath = await observer.getCurrentPath() + #expect(currentPath == nil) + } - @Test("Multiple start calls use latest queue") - internal func multipleStartCalls() async { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("Multiple start calls use latest queue") + internal func multipleStartCalls() async { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - await observer.start(queue: .global()) - let firstLabel = monitor.dispatchQueueLabel + await observer.start(queue: .global()) + let firstLabel = monitor.dispatchQueueLabel - await observer.start(queue: .main) - let secondLabel = monitor.dispatchQueueLabel + await observer.start(queue: .main) + let secondLabel = monitor.dispatchQueueLabel - #expect(firstLabel != nil) - #expect(secondLabel != nil) - // Labels should be different since we used different queues - } + #expect(firstLabel != nil) + #expect(secondLabel != nil) + // Labels should be different since we used different queues + } - // MARK: - Ping Integration Tests + // MARK: - Ping Integration Tests - @Test("Ping status updates are not tracked without ping initialization") - internal func pingStatusWithoutPing() async { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("Ping status updates are not tracked without ping initialization") + internal func pingStatusWithoutPing() async { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - await observer.start(queue: .global()) + await observer.start(queue: .global()) - let currentPingStatus = await observer.getCurrentPingStatus() - #expect(currentPingStatus == nil) - } + let currentPingStatus = await observer.getCurrentPingStatus() + #expect(currentPingStatus == nil) + } - // Note: Full ping integration testing would require NetworkMonitor-level tests - // since NetworkObserver doesn't directly manage ping lifecycle - } + // Note: Full ping integration testing would require NetworkMonitor-level tests + // since NetworkObserver doesn't directly manage ping lifecycle + } } #endif diff --git a/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift b/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift index 72e021c..36ed25b 100644 --- a/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift +++ b/Tests/SundialKitStreamTests/NetworkObserverTests+Stream.swift @@ -36,187 +36,187 @@ @testable import SundialKitStream extension NetworkObserverTests { - @Suite("Stream Tests") - internal struct StreamTests { - // MARK: - Path Updates Stream Tests - - @Test("pathUpdates stream receives initial and subsequent paths") - internal func pathUpdatesStream() async throws { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) - - await observer.start(queue: .global()) - - let stream = await observer.pathUpdates() - var iterator = stream.makeAsyncIterator() - - // Should receive initial path - let firstPath = await iterator.next() - #expect(firstPath?.pathStatus == .satisfied(.wiredEthernet)) - - // Send new path update - let newPath = MockPath( - isConstrained: true, - isExpensive: true, - pathStatus: .satisfied(.cellular) - ) - monitor.sendPath(newPath) - - // Give time for async delivery - try await Task.sleep(for: .milliseconds(10)) - - let secondPath = await iterator.next() - #expect(secondPath?.pathStatus == .satisfied(.cellular)) - #expect(secondPath?.isConstrained == true) - #expect(secondPath?.isExpensive == true) - } - - @Test("pathStatusStream extracts status from paths") - internal func pathStatusStream() async throws { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Suite("Stream Tests") + internal struct StreamTests { + // MARK: - Path Updates Stream Tests - await observer.start(queue: .global()) + @Test("pathUpdates stream receives initial and subsequent paths") + internal func pathUpdatesStream() async throws { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - try await confirmation("Received path status", expectedCount: 2) { receivedStatus in - let capture = TestValueCapture() + await observer.start(queue: .global()) - Task { @Sendable in - let stream = await observer.pathStatusStream - for await status in stream { - await capture.append(pathStatus: status) - receivedStatus() - let count = await capture.pathStatuses.count - if count >= 2 { break } - } - } + let stream = await observer.pathUpdates() + var iterator = stream.makeAsyncIterator() - // Wait briefly for initial status - try await Task.sleep(for: .milliseconds(10)) + // Should receive initial path + let firstPath = await iterator.next() + #expect(firstPath?.pathStatus == .satisfied(.wiredEthernet)) // Send new path update - let newPath = MockPath(pathStatus: .unsatisfied(.localNetworkDenied)) + let newPath = MockPath( + isConstrained: true, + isExpensive: true, + pathStatus: .satisfied(.cellular) + ) monitor.sendPath(newPath) // Give time for async delivery - try await Task.sleep(for: .milliseconds(50)) + try await Task.sleep(for: .milliseconds(10)) - let statuses = await capture.pathStatuses - #expect(statuses.count == 2) - #expect(statuses[0] == .satisfied(.wiredEthernet)) - #expect(statuses[1] == .unsatisfied(.localNetworkDenied)) + let secondPath = await iterator.next() + #expect(secondPath?.pathStatus == .satisfied(.cellular)) + #expect(secondPath?.isConstrained == true) + #expect(secondPath?.isExpensive == true) } - } - @Test("isExpensiveStream tracks expensive status") - internal func isExpensiveStream() async throws { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("pathStatusStream extracts status from paths") + internal func pathStatusStream() async throws { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - await observer.start(queue: .global()) + await observer.start(queue: .global()) - try await confirmation("Received expensive status", expectedCount: 2) { receivedValue in - let capture = TestValueCapture() + try await confirmation("Received path status", expectedCount: 2) { receivedStatus in + let capture = TestValueCapture() - Task { @Sendable in - let stream = await observer.isExpensiveStream - for await value in stream { - await capture.append(boolValue: value) - receivedValue() - let count = await capture.boolValues.count - if count >= 2 { break } + Task { @Sendable in + let stream = await observer.pathStatusStream + for await status in stream { + await capture.append(pathStatus: status) + receivedStatus() + let count = await capture.pathStatuses.count + if count >= 2 { break } + } } - } - // Wait briefly for initial value - try await Task.sleep(for: .milliseconds(10)) + // Wait briefly for initial status + try await Task.sleep(for: .milliseconds(10)) - // Send expensive path - let expensivePath = MockPath(isExpensive: true, pathStatus: .satisfied(.cellular)) - monitor.sendPath(expensivePath) + // Send new path update + let newPath = MockPath(pathStatus: .unsatisfied(.localNetworkDenied)) + monitor.sendPath(newPath) - // Give time for async delivery - try await Task.sleep(for: .milliseconds(50)) + // Give time for async delivery + try await Task.sleep(for: .milliseconds(50)) - let values = await capture.boolValues - #expect(values.count == 2) - #expect(values[0] == false) - #expect(values[1] == true) + let statuses = await capture.pathStatuses + #expect(statuses.count == 2) + #expect(statuses[0] == .satisfied(.wiredEthernet)) + #expect(statuses[1] == .unsatisfied(.localNetworkDenied)) + } } - } - @Test("isConstrainedStream tracks constrained status") - internal func isConstrainedStream() async throws { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("isExpensiveStream tracks expensive status") + internal func isExpensiveStream() async throws { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - await observer.start(queue: .global()) + await observer.start(queue: .global()) - try await confirmation("Received constrained status", expectedCount: 2) { receivedValue in - let capture = TestValueCapture() + try await confirmation("Received expensive status", expectedCount: 2) { receivedValue in + let capture = TestValueCapture() - Task { @Sendable in - let stream = await observer.isConstrainedStream - for await value in stream { - await capture.append(boolValue: value) - receivedValue() - let count = await capture.boolValues.count - if count >= 2 { break } + Task { @Sendable in + let stream = await observer.isExpensiveStream + for await value in stream { + await capture.append(boolValue: value) + receivedValue() + let count = await capture.boolValues.count + if count >= 2 { break } + } } + + // Wait briefly for initial value + try await Task.sleep(for: .milliseconds(10)) + + // Send expensive path + let expensivePath = MockPath(isExpensive: true, pathStatus: .satisfied(.cellular)) + monitor.sendPath(expensivePath) + + // Give time for async delivery + try await Task.sleep(for: .milliseconds(50)) + + let values = await capture.boolValues + #expect(values.count == 2) + #expect(values[0] == false) + #expect(values[1] == true) } + } - // Wait briefly for initial value - try await Task.sleep(for: .milliseconds(10)) + @Test("isConstrainedStream tracks constrained status") + internal func isConstrainedStream() async throws { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - // Send constrained path - let constrainedPath = MockPath(isConstrained: true, pathStatus: .satisfied(.wifi)) - monitor.sendPath(constrainedPath) + await observer.start(queue: .global()) - // Give time for async delivery - try await Task.sleep(for: .milliseconds(50)) + try await confirmation("Received constrained status", expectedCount: 2) { receivedValue in + let capture = TestValueCapture() + + Task { @Sendable in + let stream = await observer.isConstrainedStream + for await value in stream { + await capture.append(boolValue: value) + receivedValue() + let count = await capture.boolValues.count + if count >= 2 { break } + } + } + + // Wait briefly for initial value + try await Task.sleep(for: .milliseconds(10)) + + // Send constrained path + let constrainedPath = MockPath(isConstrained: true, pathStatus: .satisfied(.wifi)) + monitor.sendPath(constrainedPath) - let values = await capture.boolValues - #expect(values.count == 2) - #expect(values[0] == false) - #expect(values[1] == true) + // Give time for async delivery + try await Task.sleep(for: .milliseconds(50)) + + let values = await capture.boolValues + #expect(values.count == 2) + #expect(values[0] == false) + #expect(values[1] == true) + } } - } - // MARK: - Multiple Subscribers Tests + // MARK: - Multiple Subscribers Tests - @Test("Multiple path update subscribers receive same updates") - internal func multiplePathSubscribers() async throws { - let monitor = MockPathMonitor() - let observer = NetworkObserver(monitor: monitor) + @Test("Multiple path update subscribers receive same updates") + internal func multiplePathSubscribers() async throws { + let monitor = MockPathMonitor() + let observer = NetworkObserver(monitor: monitor) - await observer.start(queue: .global()) + await observer.start(queue: .global()) - // Create two subscribers - let stream1 = await observer.pathUpdates() - let stream2 = await observer.pathUpdates() + // Create two subscribers + let stream1 = await observer.pathUpdates() + let stream2 = await observer.pathUpdates() - var iterator1 = stream1.makeAsyncIterator() - var iterator2 = stream2.makeAsyncIterator() + var iterator1 = stream1.makeAsyncIterator() + var iterator2 = stream2.makeAsyncIterator() - // Both should receive initial path - let path1First = await iterator1.next() - let path2First = await iterator2.next() + // Both should receive initial path + let path1First = await iterator1.next() + let path2First = await iterator2.next() - #expect(path1First?.pathStatus == .satisfied(.wiredEthernet)) - #expect(path2First?.pathStatus == .satisfied(.wiredEthernet)) + #expect(path1First?.pathStatus == .satisfied(.wiredEthernet)) + #expect(path2First?.pathStatus == .satisfied(.wiredEthernet)) - // Send new path - let newPath = MockPath(pathStatus: .satisfied(.cellular)) - monitor.sendPath(newPath) + // Send new path + let newPath = MockPath(pathStatus: .satisfied(.cellular)) + monitor.sendPath(newPath) - try await Task.sleep(for: .milliseconds(10)) + try await Task.sleep(for: .milliseconds(10)) - let path1Second = await iterator1.next() - let path2Second = await iterator2.next() + let path1Second = await iterator1.next() + let path2Second = await iterator2.next() - #expect(path1Second?.pathStatus == .satisfied(.cellular)) - #expect(path2Second?.pathStatus == .satisfied(.cellular)) + #expect(path1Second?.pathStatus == .satisfied(.cellular)) + #expect(path2Second?.pathStatus == .satisfied(.cellular)) + } } } - } #endif diff --git a/Tests/SundialKitStreamTests/NetworkObserverTests.swift b/Tests/SundialKitStreamTests/NetworkObserverTests.swift index 4ba9b66..c071fa8 100644 --- a/Tests/SundialKitStreamTests/NetworkObserverTests.swift +++ b/Tests/SundialKitStreamTests/NetworkObserverTests.swift @@ -29,5 +29,8 @@ import Testing -@Suite("NetworkObserver Tests", .disabled(if: isWasm || !hasNetwork)) +@Suite( + "NetworkObserver Tests", + .enabled(if: SupportedModule([.dispatch, .network]).isSupported) +) internal enum NetworkObserverTests {} diff --git a/Tests/SundialKitStreamTests/WASMSupport.swift b/Tests/SundialKitStreamTests/SupportedModule.swift similarity index 67% rename from Tests/SundialKitStreamTests/WASMSupport.swift rename to Tests/SundialKitStreamTests/SupportedModule.swift index f24dc99..17bf4ea 100644 --- a/Tests/SundialKitStreamTests/WASMSupport.swift +++ b/Tests/SundialKitStreamTests/SupportedModule.swift @@ -27,14 +27,28 @@ // OTHER DEALINGS IN THE SOFTWARE. // -#if canImport(Dispatch) - internal let isWasm = false -#else - internal let isWasm = true -#endif +internal struct SupportedModule: OptionSet, Hashable { + internal static let dispatch: Self = .init(rawValue: 1) + internal static let network: Self = .init(rawValue: 2) -#if canImport(Network) - internal let hasNetwork = true -#else - internal let hasNetwork = false -#endif + private static let supported: Self = { + var values: [Self] = [] + #if canImport(Network) + values.append(.network) + #endif + #if canImport(Dispatch) + values.append(.dispatch) + #endif + return .init(values) + }() + + internal let rawValue: Int + + internal var isSupported: Bool { + self.isSubset(of: Self.supported) + } + + internal init(rawValue: Int) { + self.rawValue = rawValue + } +} From d00af5c053a453e1dc94125f50e86396f287e7fd Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 10 Apr 2026 09:11:09 -0400 Subject: [PATCH 08/10] fix(ci): add !cancelled() guards, remove redundant env, full matrix for tags - Add !cancelled() to build-windows and build-android job conditions for consistency with build-macos and build-macos-full - Remove redundant PACKAGE_NAME env blocks from build-macos and build-macos-full (already declared at workflow level) - Extend full-matrix condition to include tag pushes so release tags receive the same thorough validation as main/PR builds Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/SundialKitStream.yml | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/SundialKitStream.yml b/.github/workflows/SundialKitStream.yml index f6aab7e..fa69323 100644 --- a/.github/workflows/SundialKitStream.yml +++ b/.github/workflows/SundialKitStream.yml @@ -39,7 +39,9 @@ jobs: - name: Determine build matrix id: set-matrix run: | - if [[ "${{ github.ref }}" == "refs/heads/main" || "${{ github.event_name }}" == "pull_request" ]]; then + if [[ "${{ github.ref }}" == "refs/heads/main" \ + || "${{ github.event_name }}" == "pull_request" \ + || "${{ github.ref }}" == refs/tags/* ]]; then echo "full-matrix=true" >> "$GITHUB_OUTPUT" echo 'ubuntu-os=["noble","jammy"]' >> "$GITHUB_OUTPUT" echo 'ubuntu-swift=[{"version":"6.1"},{"version":"6.2"},{"version":"6.3"}]' >> "$GITHUB_OUTPUT" @@ -96,8 +98,6 @@ jobs: build-macos: name: Build on macOS needs: [configure] - env: - PACKAGE_NAME: SundialKitStream runs-on: ${{ matrix.runs-on }} if: ${{ !cancelled() && needs.configure.result == 'success' }} strategy: @@ -138,8 +138,6 @@ jobs: name: Build on macOS (Full) needs: [configure] if: ${{ !cancelled() && needs.configure.result == 'success' && needs.configure.outputs.full-matrix == 'true' }} - env: - PACKAGE_NAME: SundialKitStream runs-on: ${{ matrix.runs-on }} strategy: fail-fast: false @@ -214,7 +212,7 @@ jobs: build-windows: name: Build on Windows needs: [configure] - if: needs.configure.outputs.full-matrix == 'true' + if: ${{ !cancelled() && needs.configure.outputs.full-matrix == 'true' }} runs-on: ${{ matrix.runs-on }} strategy: fail-fast: false @@ -251,7 +249,7 @@ jobs: build-android: name: Build for Android needs: [configure] - if: needs.configure.outputs.full-matrix == 'true' + if: ${{ !cancelled() && needs.configure.outputs.full-matrix == 'true' }} runs-on: ubuntu-latest strategy: fail-fast: false From c66405ca63bc65e46bcb693668f3a59a4cc06920 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 10 Apr 2026 11:38:16 -0400 Subject: [PATCH 09/10] Updating SundialKit --- Package.resolved | 6 +++--- Package.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Package.resolved b/Package.resolved index cfa3e6e..8190310 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "4551de46d8358d6c9a07c40cfea6fde49dfd9b083cdc20f13d4194e6d189dc19", + "originHash" : "5b6fbee2654bf63e8896a2e7672e30374319207327f7e2b85ce6ce24f78994aa", "pins" : [ { "identity" : "sundialkit", "kind" : "remoteSourceControl", "location" : "https://github.com/brightdigit/SundialKit.git", "state" : { - "branch" : "atleast-v1.0.0-beta.4", - "revision" : "7a08cc684146914e3f6661d328d4a51d5226be16" + "revision" : "c74d7f224d797214b5c53c29ece1aaec65950c63", + "version" : "2.0.0-alpha.2" } } ], diff --git a/Package.swift b/Package.swift index 45367f6..300c30f 100644 --- a/Package.swift +++ b/Package.swift @@ -59,7 +59,7 @@ let package = Package( ) ], dependencies: [ - .package(url: "https://github.com/brightdigit/SundialKit.git", branch: "atleast-v1.0.0-beta.4") + .package(url: "https://github.com/brightdigit/SundialKit.git", from: "2.0.0-alpha.2") ], targets: [ .target( From d109a509d579c7c21af0d48c535809e2def796c6 Mon Sep 17 00:00:00 2001 From: Leo Dion Date: Fri, 10 Apr 2026 11:46:26 -0400 Subject: [PATCH 10/10] fix(review): address PR review items 4, 6-9 - Fix SupportedModule.swift file header (was WASMSupport.swift) - Add .unbounded back-pressure comment to all NetworkObserver AsyncStreams - Add scheme: to build-ubuntu swift-build step for consistency - Remove dead matrix params from build-macos SPM-only step - Reduce Android API matrix from [33,34,35,36] to [33,36] Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/SundialKitStream.yml | 7 ++----- Sources/SundialKitStream/NetworkObserver.swift | 5 +++++ Tests/SundialKitStreamTests/SupportedModule.swift | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/SundialKitStream.yml b/.github/workflows/SundialKitStream.yml index fa69323..ac2c581 100644 --- a/.github/workflows/SundialKitStream.yml +++ b/.github/workflows/SundialKitStream.yml @@ -74,6 +74,7 @@ jobs: - uses: brightdigit/swift-build@v1 id: build with: + scheme: ${{ env.PACKAGE_NAME }} type: ${{ matrix.type }} - name: Install curl if: steps.build.outputs.contains-code-coverage == 'true' @@ -116,11 +117,7 @@ jobs: uses: brightdigit/swift-build@v1 with: scheme: ${{ env.PACKAGE_NAME }} - type: ${{ matrix.type }} xcode: ${{ matrix.xcode }} - deviceName: ${{ matrix.deviceName }} - osVersion: ${{ matrix.osVersion }} - download-platform: ${{ matrix.download-platform }} # Common Coverage Steps - name: Process Coverage @@ -257,7 +254,7 @@ jobs: swift: - version: "6.2" - version: "6.3" - android-api-level: [33, 34, 35, 36] + android-api-level: [33, 36] steps: - uses: actions/checkout@v6 - name: Free disk space diff --git a/Sources/SundialKitStream/NetworkObserver.swift b/Sources/SundialKitStream/NetworkObserver.swift index 2730e73..7e04ab2 100644 --- a/Sources/SundialKitStream/NetworkObserver.swift +++ b/Sources/SundialKitStream/NetworkObserver.swift @@ -74,6 +74,7 @@ public import SundialKitCore /// Stream of path status changes public var pathStatusStream: AsyncStream { + // .unbounded: connectivity state changes must never be dropped under back-pressure AsyncStream(bufferingPolicy: .unbounded) { continuation in let id = UUID() pathStatusContinuations[id] = continuation @@ -88,6 +89,7 @@ public import SundialKitCore /// Stream of expensive state changes public var isExpensiveStream: AsyncStream { + // .unbounded: connectivity state changes must never be dropped under back-pressure AsyncStream(bufferingPolicy: .unbounded) { continuation in let id = UUID() isExpensiveContinuations[id] = continuation @@ -102,6 +104,7 @@ public import SundialKitCore /// Stream of constrained state changes public var isConstrainedStream: AsyncStream { + // .unbounded: connectivity state changes must never be dropped under back-pressure AsyncStream(bufferingPolicy: .unbounded) { continuation in let id = UUID() isConstrainedContinuations[id] = continuation @@ -181,6 +184,7 @@ public import SundialKitCore /// Stream of path updates public func pathUpdates() -> AsyncStream { + // .unbounded: connectivity state changes must never be dropped under back-pressure AsyncStream(bufferingPolicy: .unbounded) { continuation in let id = UUID() pathContinuations[id] = continuation @@ -198,6 +202,7 @@ public import SundialKitCore /// Stream of ping status updates public func pingStatusUpdates() -> AsyncStream { + // .unbounded: connectivity state changes must never be dropped under back-pressure AsyncStream(bufferingPolicy: .unbounded) { continuation in let id = UUID() pingStatusContinuations[id] = continuation diff --git a/Tests/SundialKitStreamTests/SupportedModule.swift b/Tests/SundialKitStreamTests/SupportedModule.swift index 17bf4ea..55f4489 100644 --- a/Tests/SundialKitStreamTests/SupportedModule.swift +++ b/Tests/SundialKitStreamTests/SupportedModule.swift @@ -1,5 +1,5 @@ // -// WASMSupport.swift +// SupportedModule.swift // SundialKitStream // // Created by Leo Dion.