diff --git a/.github/workflows/integration-test.yaml b/.github/workflows/integration-test.yaml new file mode 100644 index 0000000..702d535 --- /dev/null +++ b/.github/workflows/integration-test.yaml @@ -0,0 +1,47 @@ +name: Integration Test +on: + workflow_call: + push: + branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-integration-test + cancel-in-progress: true +jobs: + integration-test: + name: Integration Test + runs-on: ubuntu-22.04 + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + toolchain: [latest] + steps: + - name: Install Swift + uses: vapor/swiftly-action@v0.1 + with: + toolchain: ${{ matrix.toolchain }} + env: + SWIFTLY_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Checkout + uses: actions/checkout@v4.2.2 + - name: Resolve Swift dependencies + run: swift package resolve + working-directory: ./IntegrationTests + - name: Start Services + run: docker compose up -d + working-directory: ./IntegrationTests + - name: Run Integration Tests + run: swift test --parallel + working-directory: ./IntegrationTests + - name: Export service logs + if: always() + working-directory: ./IntegrationTests + run: | + docker compose logs --no-color > docker-compose-logs.txt + docker compose down + - name: Upload service logs + uses: actions/upload-artifact@v4.6.0 + if: failure() + with: + name: docker-compose-logs.txt + path: IntegrationTests/docker-compose-logs.txt diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml index 35d7e0a..2fdfdb7 100644 --- a/.github/workflows/pull-request.yaml +++ b/.github/workflows/pull-request.yaml @@ -13,3 +13,7 @@ jobs: name: Unit Test uses: ./.github/workflows/unit-test.yaml secrets: inherit + + integration_test: + name: Integration Test + uses: ./.github/workflows/integration-test.yaml diff --git a/.licenseignore b/.licenseignore index 606e6ce..dd9674a 100644 --- a/.licenseignore +++ b/.licenseignore @@ -8,6 +8,7 @@ *.txt *.yaml Package.swift +*/Package.swift .gitmodules protocol/ Makefile diff --git a/IntegrationTests/Package.swift b/IntegrationTests/Package.swift new file mode 100644 index 0000000..e382cb6 --- /dev/null +++ b/IntegrationTests/Package.swift @@ -0,0 +1,19 @@ +// swift-tools-version:6.0 +import PackageDescription + +let package = Package( + name: "swift-ofrep-integration-tests", + platforms: [.macOS(.v15)], + dependencies: [ + .package(path: "../") + ], + targets: [ + .testTarget( + name: "Integration", + dependencies: [ + .product(name: "OFREP", package: "swift-ofrep") + ] + ) + ], + swiftLanguageModes: [.v6] +) diff --git a/IntegrationTests/Tests/Integration/OFREPIntegrationTests.swift b/IntegrationTests/Tests/Integration/OFREPIntegrationTests.swift new file mode 100644 index 0000000..b8e173b --- /dev/null +++ b/IntegrationTests/Tests/Integration/OFREPIntegrationTests.swift @@ -0,0 +1,136 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift OpenFeature open source project +// +// Copyright (c) 2025 the Swift OpenFeature project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import OFREP +import OpenFeature +import ServiceLifecycle +import Testing + +@testable import Logging + +@Suite("OFREP Integration Tests") +struct OFREPIntegrationTests { + @Suite("Bool Flag Resolution") + struct BoolResolutionTests { + @Test("Static", arguments: [("static-on", true), ("static-off", false)]) + func staticBool(flag: String, expectedValue: Bool) async throws { + let provider = OFREPProvider(serverURL: URL(string: "http://localhost:8016")!) + + try await withOFREPProvider(provider) { + let resolution = OpenFeatureResolution( + value: expectedValue, + error: nil, + reason: .static, + variant: expectedValue ? "on" : "off", + flagMetadata: [:] + ) + await #expect(provider.resolution(of: flag, defaultValue: !expectedValue, context: nil) == resolution) + } + } + + @Test("Targeting Match") + func targetingMatch() async throws { + let provider = OFREPProvider(serverURL: URL(string: "http://localhost:8016")!) + + try await withOFREPProvider(provider) { + let resolution = OpenFeatureResolution( + value: true, + error: nil, + reason: .targetingMatch, + variant: "on", + flagMetadata: [:] + ) + let flag = "targeting-on" + let context = OpenFeatureEvaluationContext(targetingKey: "swift") + await #expect(provider.resolution(of: flag, defaultValue: false, context: context) == resolution) + } + } + + @Test("No Targeting Match") + func noTargetingMatch() async throws { + let provider = OFREPProvider(serverURL: URL(string: "http://localhost:8016")!) + + try await withOFREPProvider(provider) { + let resolution = OpenFeatureResolution( + value: false, + error: nil, + reason: .default, + variant: "off", + flagMetadata: [:] + ) + let flag = "targeting-on" + await #expect(provider.resolution(of: flag, defaultValue: true, context: nil) == resolution) + } + } + + @Test("Type mismatch", arguments: [true, false]) + func typeMismatch(defaultValue: Bool) async throws { + let provider = OFREPProvider(serverURL: URL(string: "http://localhost:8016")!) + + try await withOFREPProvider(provider) { + let resolution = OpenFeatureResolution( + value: defaultValue, + error: OpenFeatureResolutionError( + code: .typeMismatch, + message: #"Expected flag value of type "Bool" but received "String"."# + ), + reason: .error, + variant: "a" + ) + let flag = "static-a-b" + await #expect(provider.resolution(of: flag, defaultValue: defaultValue, context: nil) == resolution) + } + } + } + + @Test("Flag not found", arguments: [true, false]) + func flagNotFound(defaultValue: Bool) async throws { + let provider = OFREPProvider(serverURL: URL(string: "http://localhost:8016")!) + + try await withOFREPProvider(provider) { + let resolution = OpenFeatureResolution( + value: defaultValue, + error: OpenFeatureResolutionError(code: .flagNotFound, message: "flag `💩` does not exist"), + reason: .error + ) + await #expect(provider.resolution(of: "💩", defaultValue: defaultValue, context: nil) == resolution) + } + } +} + +private func withOFREPProvider( + _ provider: OFREPProvider, + perform integrationTest: @escaping @Sendable () async throws -> Void +) async throws { + let integrationTestService = IntegrationTestService(test: integrationTest) + let group = ServiceGroup( + configuration: ServiceGroupConfiguration( + services: [ + .init(service: provider), + .init(service: integrationTestService, successTerminationBehavior: .gracefullyShutdownGroup), + ], + logger: Logger(label: #function) + ) + ) + + try await group.run() +} + +private struct IntegrationTestService: Service { + let test: @Sendable () async throws -> Void + + func run() async throws { + try await test() + } +} diff --git a/IntegrationTests/docker-compose.yaml b/IntegrationTests/docker-compose.yaml new file mode 100644 index 0000000..f30494b --- /dev/null +++ b/IntegrationTests/docker-compose.yaml @@ -0,0 +1,14 @@ +name: swift-ofrep-integration-test +services: + flagd: + image: ghcr.io/open-feature/flagd:latest + ports: + - 8016:8016 # OFREP + volumes: + - ./integration.flagd.json:/etc/flagd/integration.flagd.json + command: [ + "start", + "--uri", + "file:./etc/flagd/integration.flagd.json", + "--debug" + ] diff --git a/IntegrationTests/integration.flagd.json b/IntegrationTests/integration.flagd.json new file mode 100644 index 0000000..46f64dd --- /dev/null +++ b/IntegrationTests/integration.flagd.json @@ -0,0 +1,50 @@ +{ + "$schema": "https://flagd.dev/schema/v0/flags.json", + "flags": { + "static-on": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "on" + }, + "static-off": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "off" + }, + "targeting-on": { + "state": "ENABLED", + "variants": { + "on": true, + "off": false + }, + "defaultVariant": "off", + "targeting": { + "if": [ + { + "===": [ + { + "var": "targetingKey" + }, + "swift" + ] + }, + "on" + ] + } + }, + "static-a-b": { + "state": "ENABLED", + "variants": { + "a": "a", + "b": "b" + }, + "defaultVariant": "a" + } + } +} diff --git a/README.md b/README.md index d88c9fa..b105287 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Swift OFREP [![Unit Test](https://github.com/swift-open-feature/swift-ofrep/actions/workflows/unit-test.yaml/badge.svg)](https://github.com/swift-open-feature/swift-ofrep/actions/workflows/unit-test.yaml) +[![Integration Test](https://github.com/swift-open-feature/swift-ofrep/actions/workflows/integration-test.yaml/badge.svg)](https://github.com/swift-open-feature/swift-ofrep/actions/workflows/integration-test.yaml) [![codecov](https://codecov.io/gh/swift-open-feature/swift-ofrep/graph/badge.svg?token=YK7Y25KOFU)](https://codecov.io/gh/swift-open-feature/swift-ofrep) A cross-platform [OFREP](https://github.com/open-feature/protocol) provider for Swift,