diff --git a/.gitignore b/.gitignore index 1c44ba6f2..495ddcb39 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ SampleApp.xcworkspace # ios-snapshot-test-case Failure Diffs FailureDiffs/ + +Samples/**/*Info.plist +!Samples/Tutorial/AppHost/Configuration/Info.plist +!Samples/Tutorial/AppHost/TutorialTests/Info.plist \ No newline at end of file diff --git a/Development.podspec b/Development.podspec index 274cf3b08..cd94e5ad5 100644 --- a/Development.podspec +++ b/Development.podspec @@ -13,6 +13,7 @@ Pod::Spec.new do |s| s.dependency 'WorkflowUI' s.dependency 'WorkflowReactiveSwift' s.dependency 'WorkflowRxSwift' + # s.dependency 'WorkflowCombine' # TODO: Disabled because app specs cannot increase the deployment target of the root s.source_files = 'Samples/Dummy.swift' s.subspec 'Dummy' do |ss| @@ -141,4 +142,34 @@ Pod::Spec.new do |s| test_spec.dependency 'WorkflowTesting' test_spec.dependency 'WorkflowRxSwiftTesting' end + + # TODO: Disabled because app specs cannot increase the deployment target of the root + # To use, increase the deployment target of this spec to 13.0 or higher + # s.app_spec 'WorkflowCombineSampleApp' do |app_spec| + # app_spec.source_files = 'Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/**/*.swift' + # end + # + # s.test_spec 'WorkflowCombineSampleAppTests' do |test_spec| + # test_spec.dependency 'Development/WorkflowCombineSampleApp' + # test_spec.dependency 'WorkflowTesting' + # test_spec.requires_app_host = true + # test_spec.app_host_name = 'Development/WorkflowCombineSampleApp' + # test_spec.source_files = 'Samples/WorkflowCombineSampleApp/WorkflowCombineSampleAppUnitTests/**/*.swift' + # end + + # s.test_spec 'WorkflowCombineTests' do |test_spec| + # test_spec.requires_app_host = true + # test_spec.source_files = 'WorkflowCombine/Tests/**/*.swift' + # test_spec.framework = 'XCTest' + # test_spec.dependency 'WorkflowTesting' + # test_spec.dependency 'WorkflowCombineTesting' + # end + + # s.test_spec 'WorkflowCombineTestingTests' do |test_spec| + # test_spec.requires_app_host = true + # test_spec.source_files = 'WorkflowCombine/TestingTests/**/*.swift' + # test_spec.framework = 'XCTest' + # test_spec.dependency 'WorkflowTesting' + # test_spec.dependency 'WorkflowCombineTesting' + # end end diff --git a/Package.swift b/Package.swift index 304565406..21dd18027 100644 --- a/Package.swift +++ b/Package.swift @@ -26,6 +26,10 @@ let package = Package( name: "WorkflowReactiveSwift", targets: ["WorkflowReactiveSwift"] ), + .library( + name: "WorkflowCombine", + targets: ["WorkflowCombine"] + ), ], dependencies: [ .package(url: "https://github.com/ReactiveCocoa/ReactiveSwift.git", from: "6.3.0"), @@ -62,6 +66,11 @@ let package = Package( dependencies: ["ReactiveSwift", "Workflow"], path: "WorkflowReactiveSwift/Sources" ), + .target( + name: "WorkflowCombine", + dependencies: ["Workflow"], + path: "WorkflowCombine/Sources" + ), ], swiftLanguageVersions: [.v5] ) diff --git a/Samples/SampleSwiftUIApp/SampleSwiftUIApp/Info.plist b/Samples/SampleSwiftUIApp/SampleSwiftUIApp/Info.plist deleted file mode 100644 index 41456fbdd..000000000 --- a/Samples/SampleSwiftUIApp/SampleSwiftUIApp/Info.plist +++ /dev/null @@ -1,60 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - - - - - UILaunchStoryboardName - - UIRequiredDeviceCapabilities - - armv7 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/Samples/TicTacToe/Tests/Info.plist b/Samples/TicTacToe/Tests/Info.plist deleted file mode 100644 index 64d65ca49..000000000 --- a/Samples/TicTacToe/Tests/Info.plist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - - diff --git a/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/AppDelegate.swift b/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/AppDelegate.swift new file mode 100644 index 000000000..5e29780c2 --- /dev/null +++ b/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/AppDelegate.swift @@ -0,0 +1,22 @@ +// +// AppDelegate.swift +// WorkflowCombineSampleApp +// +// Created by Soo Rin Park on 10/28/21. +// + +import UIKit +import WorkflowUI + +@UIApplicationMain +class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + window = UIWindow(frame: UIScreen.main.bounds) + window?.rootViewController = ContainerViewController(workflow: DemoWorkflow()) + window?.makeKeyAndVisible() + + return true + } +} diff --git a/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/DemoViewController.swift b/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/DemoViewController.swift new file mode 100644 index 000000000..1927c7c5d --- /dev/null +++ b/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/DemoViewController.swift @@ -0,0 +1,31 @@ +// +// DemoViewController.swift +// WorkflowCombineSampleApp +// +// Created by Soo Rin Park on 10/28/21. +// + +import Foundation +import UIKit +import WorkflowUI + +class DemoViewController: ScreenViewController { + private let label = UILabel() + + override func viewDidLoad() { + super.viewDidLoad() + + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + label.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true + label.topAnchor.constraint(equalTo: view.topAnchor).isActive = true + label.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true + label.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true + } + + override func screenDidChange(from previousScreen: DemoScreen, previousEnvironment: ViewEnvironment) { + super.screenDidChange(from: previousScreen, previousEnvironment: previousEnvironment) + + label.text = screen.date + } +} diff --git a/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/DemoWorker.swift b/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/DemoWorker.swift new file mode 100644 index 000000000..0721ceeee --- /dev/null +++ b/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/DemoWorker.swift @@ -0,0 +1,28 @@ +// +// DemoWorker.swift +// WorkflowCombineSampleApp +// +// Created by Soo Rin Park on 10/28/21. +// + +import Combine +import Workflow +import WorkflowCombine + +// MARK: Workers + +extension DemoWorkflow { + struct DemoWorker: WorkflowCombine.Worker { + typealias Output = Action + + // This publisher publishes the current date on a timer that fires every second + func run() -> AnyPublisher { + Timer.publish(every: 1, on: .main, in: .common) + .autoconnect() + .map { Action(publishedDate: $0) } + .eraseToAnyPublisher() + } + + func isEquivalent(to otherWorker: DemoWorkflow.DemoWorker) -> Bool { true } + } +} diff --git a/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/DemoWorkflow.swift b/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/DemoWorkflow.swift new file mode 100644 index 000000000..b94eb8855 --- /dev/null +++ b/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleApp/DemoWorkflow.swift @@ -0,0 +1,71 @@ +// +// DemoWorkflow.swift +// WorkflowCombineSampleApp +// +// Created by Soo Rin Park on 10/28/21. +// + +import Workflow +import WorkflowUI + +// MARK: Input and Output + +let dateFormatter = DateFormatter() + +struct DemoWorkflow: Workflow { + typealias Output = Never +} + +// MARK: State and Initialization + +extension DemoWorkflow { + struct State: Equatable { + var date: Date + } + + func makeInitialState() -> DemoWorkflow.State { State(date: Date()) } + + func workflowDidChange(from previousWorkflow: DemoWorkflow, state: inout State) {} +} + +// MARK: Actions + +extension DemoWorkflow { + struct Action: WorkflowAction { + typealias WorkflowType = DemoWorkflow + + let publishedDate: Date + + func apply(toState state: inout DemoWorkflow.State) -> DemoWorkflow.Output? { + state.date = publishedDate + return nil + } + } +} + +// MARK: Rendering + +extension DemoWorkflow { + // TODO: Change this to your actual rendering type + typealias Rendering = DemoScreen + + func render(state: DemoWorkflow.State, context: RenderContext) -> Rendering { + DemoWorker() + .rendered(in: context) + + dateFormatter.dateStyle = .long + dateFormatter.timeStyle = .long + let formattedDate = dateFormatter.string(from: state.date) + let rendering = Rendering(date: formattedDate) + + return rendering + } +} + +struct DemoScreen: Screen { + let date: String + + func viewControllerDescription(environment: ViewEnvironment) -> ViewControllerDescription { + DemoViewController.description(for: self, environment: environment) + } +} diff --git a/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleAppUnitTests/DemoWorkflowTests.swift b/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleAppUnitTests/DemoWorkflowTests.swift new file mode 100644 index 000000000..1ee01c4fb --- /dev/null +++ b/Samples/WorkflowCombineSampleApp/WorkflowCombineSampleAppUnitTests/DemoWorkflowTests.swift @@ -0,0 +1,24 @@ +// +// DemoWorkflowTests.swift +// WorkflowCombineSampleAppUnitTests +// +// Created by Soo Rin Park on 11/1/21. +// + +import Combine +import Workflow +import WorkflowTesting +import XCTest +@testable import Development_WorkflowCombineSampleApp + +class DemoWorkflowTests: XCTestCase { + func test_demoWorkflow_publishesNewDate() { + let expectedDate = Date(timeIntervalSince1970: 0) + + DemoWorkflow + .Action + .tester(withState: .init(date: Date())) // the initial date itself does not matter + .send(action: .init(publishedDate: expectedDate)) + .assert(state: .init(date: expectedDate)) + } +} diff --git a/WorkflowCombine.podspec b/WorkflowCombine.podspec new file mode 100644 index 000000000..0c6e61232 --- /dev/null +++ b/WorkflowCombine.podspec @@ -0,0 +1,24 @@ +require_relative('version') + +Pod::Spec.new do |s| + s.name = 'WorkflowCombine' + s.version = WORKFLOW_VERSION + s.summary = 'Infrastructure for Combined-powered Workers' + s.homepage = 'https://www.github.com/square/workflow-swift' + s.license = 'Apache License, Version 2.0' + s.author = 'Square' + s.source = { :git => 'https://github.com/square/workflow-swift.git', :tag => "v#{s.version}" } + + # 1.7 is needed for `swift_versions` support + s.cocoapods_version = '>= 1.7.0' + + s.swift_versions = ['5.1'] + s.ios.deployment_target = '13.0' + s.osx.deployment_target = '10.15' + + s.source_files = 'WorkflowCombine/Sources/*.swift' + + s.dependency 'Workflow', "#{s.version}" + + end + diff --git a/WorkflowCombine/Sources/Logger.swift b/WorkflowCombine/Sources/Logger.swift new file mode 100644 index 000000000..1d5f83fe4 --- /dev/null +++ b/WorkflowCombine/Sources/Logger.swift @@ -0,0 +1,63 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import os.signpost + +private extension OSLog { + static let worker = OSLog(subsystem: "com.squareup.WorkflowCombine", category: "Worker") +} + +@available(iOS 13.0, macOS 10.15, *) +/// Logs Worker events to OSLog +final class WorkerLogger { + init() {} + + var signpostID: OSSignpostID { OSSignpostID(log: .worker, object: self) } + + // MARK: - Workers + + func logStarted() { + os_signpost( + .begin, + log: .worker, + name: "Running", + signpostID: signpostID, + "Worker: %{private}@", + String(describing: WorkerType.self) + ) + } + + func logFinished(status: StaticString) { + os_signpost( + .end, + log: .worker, + name: "Running", + signpostID: signpostID, + status + ) + } + + func logOutput() { + os_signpost( + .event, + log: .worker, + name: "Worker Event", + signpostID: signpostID, + "Event: %{private}@", + String(describing: WorkerType.self) + ) + } +} diff --git a/WorkflowCombine/Sources/Publisher+Extensions.swift b/WorkflowCombine/Sources/Publisher+Extensions.swift new file mode 100644 index 000000000..d2b4c0e09 --- /dev/null +++ b/WorkflowCombine/Sources/Publisher+Extensions.swift @@ -0,0 +1,34 @@ +// +// Publisher+Extensions.swift +// WorkflowCombine +// +// Created by Soo Rin Park on 11/3/21. +// + +#if canImport(Combine) && swift(>=5.1) + + import Combine + import Foundation + import Workflow + + @available(iOS 13.0, macOS 10.15, *) + /// This is a workaround to the fact you extensions of protocols cannot have an inheritance clause. + /// a previous solution had extending the `AnyPublisher` to conform to `AnyWorkflowConvertible`, + /// but was limited in the fact that rendering was only available to `AnyPublisher`s. + /// this solutions makes it so that all publishers can render its view. + extension Publisher where Failure == Never { + public func running(in context: RenderContext, key: String = "") where + Output == AnyWorkflowAction { + asAnyWorkflow().rendered(in: context, key: key, outputMap: { $0 }) + } + + public func mapOutput(_ transform: @escaping (Output) -> NewOutput) -> AnyWorkflow { + asAnyWorkflow().mapOutput(transform) + } + + func asAnyWorkflow() -> AnyWorkflow { + PublisherWorkflow(publisher: self).asAnyWorkflow() + } + } + +#endif diff --git a/WorkflowCombine/Sources/PublisherWorkflow.swift b/WorkflowCombine/Sources/PublisherWorkflow.swift new file mode 100644 index 000000000..8bed90a07 --- /dev/null +++ b/WorkflowCombine/Sources/PublisherWorkflow.swift @@ -0,0 +1,50 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#if canImport(Combine) && swift(>=5.1) + + import Combine + import Foundation + import Workflow + + @available(iOS 13.0, macOS 10.15, *) + struct PublisherWorkflow: Workflow where WorkflowPublisher.Failure == Never { + public typealias Output = WorkflowPublisher.Output + public typealias State = Void + public typealias Rendering = Void + + let publisher: WorkflowPublisher + + public init(publisher: WorkflowPublisher) { + self.publisher = publisher + } + + public func render(state: State, context: RenderContext) -> Rendering { + let sink = context.makeSink(of: AnyWorkflowAction.self) + context.runSideEffect(key: "") { [publisher] lifetime in + let cancellable = publisher + .map { AnyWorkflowAction(sendingOutput: $0) } + .subscribe(on: RunLoop.main) + .sink { sink.send($0) } + + lifetime.onEnded { + cancellable.cancel() + } + } + } + } + +#endif diff --git a/WorkflowCombine/Sources/Worker.swift b/WorkflowCombine/Sources/Worker.swift new file mode 100644 index 000000000..d80783882 --- /dev/null +++ b/WorkflowCombine/Sources/Worker.swift @@ -0,0 +1,102 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#if canImport(Combine) && swift(>=5.1) + + import Combine + import Foundation + import Workflow + + /// Workers define a unit of asynchronous work. + /// + /// During a render pass, a workflow can ask the context to await the result of a worker. + /// + /// When this occurs, the context checks to see if there is already a running worker of the same type. + /// If there is, and if the workers are 'equivalent', the context leaves the existing worker running. + /// + /// If there is not an existing worker of this type, the context will kick off the new worker (via `run`). + @available(iOS 13.0, macOS 10.15, *) + public protocol Worker: AnyWorkflowConvertible where Rendering == Void { + /// The type of output events returned by this worker. + associatedtype Output + associatedtype WorkerPublisher: Publisher where + WorkerPublisher.Output == Output, WorkerPublisher.Failure == Never + + /// Returns a publisher to execute the work represented by this worker. + func run() -> WorkerPublisher + /// Returns `true` if the other worker should be considered equivalent to `self`. Equivalence should take into + /// account whatever data is meaningful to the task. For example, a worker that loads a user account from a server + /// would not be equivalent to another worker with a different user ID. + func isEquivalent(to otherWorker: Self) -> Bool + } + + @available(iOS 13.0, macOS 10.15, *) + extension Worker { + public func asAnyWorkflow() -> AnyWorkflow { + WorkerWorkflow(worker: self).asAnyWorkflow() + } + } + + @available(iOS 13.0, macOS 10.15, *) + struct WorkerWorkflow: Workflow { + let worker: WorkerType + + typealias Output = WorkerType.Output + typealias Rendering = Void + typealias State = UUID + + func makeInitialState() -> State { UUID() } + + func workflowDidChange(from previousWorkflow: WorkerWorkflow, state: inout UUID) { + if !worker.isEquivalent(to: previousWorkflow.worker) { + state = UUID() + } + } + + func render(state: State, context: RenderContext) -> Rendering { + let logger = WorkerLogger() + + Deferred { + worker.run() + .handleEvents( + receiveSubscription: { _ in + logger.logStarted() + }, + receiveOutput: { output in + logger.logOutput() + }, + receiveCompletion: { completion in + // no need to switch completion since Failure is hardcoded to Never + logger.logFinished(status: "Finished") + }, + receiveCancel: { + logger.logFinished(status: "Cancelled") + } + ) + .map { AnyWorkflowAction(sendingOutput: $0) } + } + .running(in: context, key: state.uuidString) + } + } + + @available(iOS 13.0, macOS 10.15, *) + extension Worker where Self: Equatable { + public func isEquivalent(to otherWorker: Self) -> Bool { + self == otherWorker + } + } + +#endif diff --git a/WorkflowCombine/Testing/PublisherTesting.swift b/WorkflowCombine/Testing/PublisherTesting.swift new file mode 100644 index 000000000..c415c8938 --- /dev/null +++ b/WorkflowCombine/Testing/PublisherTesting.swift @@ -0,0 +1,48 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#if DEBUG + + import Combine + import Workflow + import WorkflowTesting + import XCTest + @testable import WorkflowCombine + + extension RenderTester { + /// Expect a `Publisher`s. + /// + /// `PublisherWorkflow` is used to subscribe to `Publisher`s. + /// + /// - Parameters: + /// - producingOutput: An output that should be returned when this worker is requested, if any. + /// - key: Key to expect this `Workflow` to be rendered with. + public func expectPublisher( + publisher: PublisherType.Type, + output: PublisherType.Output, + key: String = "" + ) -> RenderTester where PublisherType.Failure == Never { + expectWorkflow( + type: PublisherWorkflow.self, + key: key, + producingRendering: (), + producingOutput: output, + assertions: { _ in } + ) + } + } + +#endif diff --git a/WorkflowCombine/Testing/WorkerTesting.swift b/WorkflowCombine/Testing/WorkerTesting.swift new file mode 100644 index 000000000..d37e3e5b3 --- /dev/null +++ b/WorkflowCombine/Testing/WorkerTesting.swift @@ -0,0 +1,54 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#if DEBUG + import Workflow + import WorkflowTesting + import XCTest + @testable import WorkflowCombine + + extension RenderTester { + /// Expect the given worker. It will be checked for `isEquivalent(to:)` with the requested worker. + /// + /// - Parameters: + /// - worker: The worker to be expected + /// - producingOutput: An output that should be returned when this worker is requested, if any. + /// - key: Key to expect this `Workflow` to be rendered with. + public func expect( + worker: ExpectedWorkerType, + producingOutput output: ExpectedWorkerType.Output? = nil, + key: String = "", + file: StaticString = #file, line: UInt = #line + ) -> RenderTester { + expectWorkflow( + type: WorkerWorkflow.self, + key: key, + producingRendering: (), + producingOutput: output, + assertions: { workflow in + guard !workflow.worker.isEquivalent(to: worker) else { + return + } + XCTFail( + "Workers of type \(ExpectedWorkerType.self) not equivalent. Expected: \(worker). Got: \(workflow.worker)", + file: file, + line: line + ) + } + ) + } + } +#endif diff --git a/WorkflowCombine/TestingTests/PublisherTests.swift b/WorkflowCombine/TestingTests/PublisherTests.swift new file mode 100644 index 000000000..d9f9e3bb7 --- /dev/null +++ b/WorkflowCombine/TestingTests/PublisherTests.swift @@ -0,0 +1,33 @@ +// +// PublisherTests.swift +// WorkflowCombine +// +// Created by Soo Rin Park on 11/3/21. +// + +import Combine +import Foundation +import Workflow +import WorkflowTesting +import XCTest +@testable import WorkflowCombineTesting + +class PublisherTests: XCTestCase { + func testPublisherWorkflow() { + TestWorkflow() + .renderTester() + .expectPublisher(publisher: Publishers.Sequence<[Int], Never>.self, output: 1, key: "123") + .render {} + } + + struct TestWorkflow: Workflow { + typealias State = Void + typealias Rendering = Void + + func render(state: State, context: RenderContext) -> Rendering { + [1].publisher + .mapOutput { _ in AnyWorkflowAction.noAction } + .running(in: context, key: "123") + } + } +} diff --git a/WorkflowCombine/TestingTests/TestingTests.swift b/WorkflowCombine/TestingTests/TestingTests.swift new file mode 100644 index 000000000..679d17d8b --- /dev/null +++ b/WorkflowCombine/TestingTests/TestingTests.swift @@ -0,0 +1,195 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Combine +import Workflow +import WorkflowCombine +import WorkflowCombineTesting +import WorkflowTesting +import XCTest + +class WorkflowCombineTestingTests: XCTestCase { + func test_workers() { + let renderTester = TestWorkflow() + .renderTester(initialState: .init(mode: .worker(input: "otherText"), output: "")) + + renderTester + .expect(worker: TestWorker(input: "otherText")) + .render { _ in } + } + + func test_workerOutput_updatesState() { + let renderTester = TestWorkflow() + .renderTester(initialState: .init(mode: .worker(input: "otherText"), output: "")) + + renderTester + .expect( + worker: TestWorker(input: "otherText"), + producingOutput: "otherText" + ) + .render { _ in } + .verifyState { state in + XCTAssertEqual(state, TestWorkflow.State(mode: .worker(input: "otherText"), output: "otherText")) + } + } + + func test_worker_missing() { + let tester = TestWorkflow() + .renderTester() + .expect( + worker: TestWorker(input: "input") + ) + + expectingFailure(#"Expected child workflow of type: WorkerWorkflow, key: """#) { + tester.render { _ in } + } + } + + func test_worker_mismatch() { + let tester = TestWorkflow() + .renderTester(initialState: .init(mode: .worker(input: "test"), output: "")) + .expect( + worker: TestWorker(input: "not-test") + ) + + expectingFailures([ + #"Workers of type TestWorker not equivalent. Expected: TestWorker(input: "not-test"). Got: TestWorker(input: "test")"#, + ]) { + tester.render { _ in } + } + } + + func test_worker_unexpected() { + let tester = TestWorkflow() + .renderTester(initialState: .init(mode: .worker(input: "test"), output: "")) + + expectingFailure(#"Unexpected workflow of type WorkerWorkflow with key """#) { + tester.render { _ in } + } + } + + // MARK: - Failure Recording + + var expectedFailureStrings: [String] = [] + + @discardableResult + func expectingFailure( + _ messageSubstring: String, + file: StaticString = #file, line: UInt = #line, + perform: () -> Result + ) -> Result { + return expectingFailures([messageSubstring], file: file, line: line, perform: perform) + } + + @discardableResult + func expectingFailures( + _ messageSubstrings: [String], + file: StaticString = #file, line: UInt = #line, + perform: () -> Result + ) -> Result { + expectedFailureStrings = messageSubstrings + let result = perform() + if !expectedFailureStrings.isEmpty { + let leftOverExpectedFailures = expectedFailureStrings + expectedFailureStrings = [] + for failure in leftOverExpectedFailures { + XCTFail(#"Expected failure matching "\#(failure)""#, file: file, line: line) + } + } + return result + } + + /// Check for the given failure description and remove it if there’s a matching expected failure + /// - Parameter description: The failure description to check & remove + /// - Returns: `true` if the failure was expected and removed, otherwise `false` + private func removeFailure(withDescription description: String) -> Bool { + if let matchedIndex = expectedFailureStrings.firstIndex(where: { description.contains($0) }) { + expectedFailureStrings.remove(at: matchedIndex) + return true + } else { + return false + } + } + + #if swift(>=5.3) + + // Undeprecated API on Xcode 12+ (which ships with Swift 5.3) + override func record(_ issue: XCTIssue) { + if removeFailure(withDescription: issue.compactDescription) { + // Don’t forward the issue, it was expected + } else { + super.record(issue) + } + } + + #else + + // Otherwise, use old API + override func recordFailure(withDescription description: String, inFile filePath: String, atLine lineNumber: Int, expected: Bool) { + if removeFailure(withDescription: description) { + // Don’t forward the failure, it was expected + } else { + super.recordFailure(withDescription: description, inFile: filePath, atLine: lineNumber, expected: expected) + } + } + + #endif +} + +private struct TestWorkflow: Workflow { + struct State: Equatable { + enum Mode: Equatable { + case idle + case worker(input: String) + } + + let mode: Mode + var output: String + } + + func makeInitialState() -> State { + .init(mode: .idle, output: "") + } + + func workflowDidChange(from previousWorkflow: TestWorkflow, state: inout State) {} + + func render(state: State, context: RenderContext) { + switch state.mode { + case .idle: + break + case .worker(input: let input): + TestWorker(input: input) + .mapOutput { output in + AnyWorkflowAction { + $0.output = output + return nil + } + } + .running(in: context) + } + } +} + +private struct TestWorker: Worker { + typealias Output = String + typealias WorkerPublisher = Just + + let input: String + + func run() -> WorkerPublisher { Just(input) } + + func isEquivalent(to otherWorker: TestWorker) -> Bool { input == otherWorker.input } +} diff --git a/WorkflowCombine/Tests/PublisherTests.swift b/WorkflowCombine/Tests/PublisherTests.swift new file mode 100644 index 000000000..e4984571b --- /dev/null +++ b/WorkflowCombine/Tests/PublisherTests.swift @@ -0,0 +1,88 @@ +/* + * Copyright 2021 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Combine +import Foundation +import Workflow +import WorkflowCombineTesting +import XCTest +@testable import WorkflowCombine + +class PublisherTests: XCTestCase { + func test_publisherWorkflow_usesSideEffectWithKey() { + PublisherWorkflow(publisher: Just(1)) + .renderTester() + .expectSideEffect(key: "") + .render { _ in } + } + + func test_output() { + let host = WorkflowHost( + workflow: PublisherWorkflow(publisher: Just(1)) + ) + + let expectation = XCTestExpectation() + var outputValue: Int? + let disposable = host.output.signal.observeValues { output in + outputValue = output + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + XCTAssertEqual(1, outputValue) + + disposable?.dispose() + } + + func test_multipleOutputs() { + let publisher = [1, 2, 3].publisher + + let host = WorkflowHost( + workflow: PublisherWorkflow(publisher: publisher) + ) + + let expectation = XCTestExpectation() + var outputValues = [Int]() + let disposable = host.output.signal.observeValues { output in + outputValues.append(output) + expectation.fulfill() + } + + wait(for: [expectation], timeout: 1) + XCTAssertEqual([1, 2, 3], outputValues) + + disposable?.dispose() + } + + func test_publisher_isDisposedIfNotUsedInWorkflow() { + let expectation = XCTestExpectation(description: "SignalProducer should be disposed if no longer used.") + let publisher = [1, 2, 3] + .publisher + .handleEvents(receiveCompletion: { _ in + expectation.fulfill() + }) + .eraseToAnyPublisher() + + let host = WorkflowHost( + workflow: PublisherWorkflow(publisher: publisher) + ) + + let publisherTwo = [1, 2, 3].publisher.eraseToAnyPublisher() + host.update(workflow: PublisherWorkflow(publisher: publisherTwo)) + + wait(for: [expectation], timeout: 1) + } +} diff --git a/WorkflowCombine/Tests/WorkerTests.swift b/WorkflowCombine/Tests/WorkerTests.swift new file mode 100644 index 000000000..f1cf8a3cf --- /dev/null +++ b/WorkflowCombine/Tests/WorkerTests.swift @@ -0,0 +1,199 @@ +/* + * Copyright 2020 Square Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Combine +import Workflow +import XCTest +@testable import WorkflowCombine + +class WorkerTests: XCTestCase { + func testExpectedWorker() { + PublisherTestWorkflow(key: "123") + .renderTester() + .expectWorkflow( + type: WorkerWorkflow.self, + key: "123", + producingRendering: (), + producingOutput: 1, + assertions: { _ in } + ) + .render { _ in } + .verifyState { state in + XCTAssertEqual(state, 1) + } + } + + func testWorkerOutput() { + let host = WorkflowHost( + workflow: PublisherTestWorkflow(key: "") + ) + + let expectation = XCTestExpectation() + let disposable = host.rendering.signal.observeValues { rendering in + expectation.fulfill() + } + + XCTAssertEqual(0, host.rendering.value) + + wait(for: [expectation], timeout: 1.0) + XCTAssertEqual(1, host.rendering.value) + + disposable?.dispose() + } + + // A worker declared on a first `render` pass that is not on a subsequent should have the work cancelled. + func test_cancelsWorkers() { + struct WorkerWorkflow: Workflow { + typealias State = Void + + enum Mode { + case notWorking + case working(start: XCTestExpectation, end: XCTestExpectation) + } + + let mode: Mode + + func render(state: State, context: RenderContext) -> Bool { + switch mode { + case .notWorking: + return false + case .working(start: let startExpectation, end: let endExpectation): + ExpectingWorker( + startExpectation: startExpectation, + endExpectation: endExpectation + ) + .mapOutput { _ in AnyWorkflowAction.noAction } + .running(in: context) + return true + } + } + + struct ExpectingWorker: Worker { + typealias Output = Void + + let startExpectation: XCTestExpectation + let endExpectation: XCTestExpectation + + func run() -> AnyPublisher { + Just(()) + .handleEvents( + receiveSubscription: { _ in + self.startExpectation.fulfill() + + }, + receiveCompletion: { _ in + self.endExpectation.fulfill() + } + ) + .eraseToAnyPublisher() + } + + func isEquivalent(to otherWorker: WorkerWorkflow.ExpectingWorker) -> Bool { + true + } + } + } + + let startExpectation = XCTestExpectation() + let endExpectation = XCTestExpectation() + let host = WorkflowHost( + workflow: WorkerWorkflow(mode: .working( + start: startExpectation, + end: endExpectation + )) + ) + + wait(for: [startExpectation], timeout: 1.0) + + host.update(workflow: WorkerWorkflow(mode: .notWorking)) + + wait(for: [endExpectation], timeout: 1.0) + } + + func test_handlesRepeatedWorkerOutputs() { + struct WF: Workflow { + typealias Output = Int + typealias Rendering = Void + + func render(state: Void, context: RenderContext) { + TestWorker() + .mapOutput { AnyWorkflowAction(sendingOutput: $0) } + .running(in: context) + } + } + + struct TestWorker: Worker { + typealias Output = Int + + func isEquivalent(to otherWorker: TestWorker) -> Bool { + true + } + + func run() -> AnyPublisher { + [1, 2].publisher + .delay(for: .milliseconds(1), scheduler: RunLoop.main) + .eraseToAnyPublisher() + } + } + + let expectation = XCTestExpectation(description: "Test Worker") + + let host = WorkflowHost(workflow: WF()) + + var outputs: [Int] = [] + host.output.signal.observeValues { output in + outputs.append(output) + + if outputs.count == 2 { + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 1.0) + + XCTAssertEqual(outputs, [1, 2]) + } +} + +private struct PublisherTestWorkflow: Workflow { + typealias State = Int + typealias Rendering = Int + + let key: String + + func makeInitialState() -> Int { + 0 + } + + func render(state: Int, context: RenderContext) -> Int { + PublisherTestWorker() + .mapOutput { output in + AnyWorkflowAction { state in + state = output + return nil + } + } + .running(in: context, key: key) + return state + } +} + +private struct PublisherTestWorker: Worker { + typealias Output = Int + func run() -> Just { Just(1) } + + func isEquivalent(to otherWorker: PublisherTestWorker) -> Bool { true } +} diff --git a/WorkflowCombineTesting.podspec b/WorkflowCombineTesting.podspec new file mode 100644 index 000000000..b8e8fa580 --- /dev/null +++ b/WorkflowCombineTesting.podspec @@ -0,0 +1,34 @@ +require_relative('version') + +Pod::Spec.new do |s| + s.name = 'WorkflowCombineTesting' + s.version = WORKFLOW_VERSION + s.summary = 'Infrastructure for Combined-powered Workers' + s.homepage = 'https://www.github.com/square/workflow-swift' + s.license = 'Apache License, Version 2.0' + s.author = 'Square' + s.source = { :git => 'https://github.com/square/workflow-swift.git', :tag => "v#{s.version}" } + + # 1.7 is needed for `swift_versions` support + s.cocoapods_version = '>= 1.7.0' + + s.swift_versions = ['5.1'] + s.ios.deployment_target = '13.0' + s.osx.deployment_target = '10.15' + + s.source_files = 'WorkflowCombine/Testing/**/*.swift' + + s.dependency 'Workflow', "#{s.version}" + s.dependency 'WorkflowCombine', "#{s.version}" + s.dependency 'WorkflowTesting', "#{s.version}" + + s.framework = 'XCTest' + + s.test_spec 'WorkflowCombineTestingTests' do |test_spec| + test_spec.requires_app_host = true + test_spec.source_files = 'WorkflowCombine/TestingTests/**/*.swift' + test_spec.framework = 'XCTest' + test_spec.dependency 'WorkflowTesting' + test_spec.library = 'swiftos' + end +end