Skip to content

Commit 39f3a9f

Browse files
authored
Add time-skipping client interceptor for test environments (#86)
Adds `TimeSkippingClientInterceptor` that automatically unlocks time skipping when waiting for workflow results in time-skipping test servers. Adds programmatic time control methods (sleep, currentTime) on `TemporalTestServer`.
1 parent 1f8cab9 commit 39f3a9f

File tree

6 files changed

+295
-6
lines changed

6 files changed

+295
-6
lines changed

.github/workflows/pull_request.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ jobs:
1313
uses: swiftlang/github-workflows/.github/workflows/soundness.yml@0.0.7
1414
with:
1515
license_header_check_project_name: "Swift Temporal SDK"
16+
api_breakage_check_enabled: false
1617

1718
unit-tests:
1819
name: Unit tests

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ let package = Package(
109109
name: "TemporalTestKit",
110110
dependencies: [
111111
.product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"),
112+
.product(name: "GRPCServiceLifecycle", package: "grpc-swift-extras"),
112113
.target(name: "Temporal"),
113114
]
114115
),

Sources/TemporalTestKit/TemporalTestServer.swift

Lines changed: 149 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
#if canImport(Testing)
1616
import GRPCNIOTransportHTTP2Posix
1717
import GRPCCore
18+
import GRPCServiceLifecycle
1819
import NIOPosix
20+
import SwiftProtobuf
1921
public import Logging
2022
public import Temporal
2123
public import Testing
@@ -108,7 +110,20 @@ public struct TemporalTestServer: Sendable {
108110
@TaskLocal
109111
public static var timeSkippingTestServer: TemporalTestServer? = nil
110112

113+
/// Whether this server supports time skipping.
114+
public var supportsTimeSkipping: Bool {
115+
self.testServiceClient != nil
116+
}
117+
111118
private let serverTarget: String
119+
private let testServiceClient: Api.Testservice.V1.TestService.Client<HTTP2ClientTransport.Posix>?
120+
@TaskLocal
121+
private static var autoTimeSkippingDisabled: Bool = false
122+
123+
/// Whether automatic time skipping is currently enabled.
124+
var isAutoTimeSkippingEnabled: Bool {
125+
self.supportsTimeSkipping && !Self.autoTimeSkippingDisabled
126+
}
112127

113128
// tune the dev server for high throughput during parallel testing, otherwise running into "resource exhausted" errors
114129
private static let devServerOptions: BridgeTestServer.DevServerOptions = {
@@ -159,7 +174,8 @@ public struct TemporalTestServer: Sendable {
159174
devServerOptions: Self.devServerOptions,
160175
) { bridgeTestServer, target in
161176
let testServer = TemporalTestServer(
162-
serverTarget: target
177+
serverTarget: target,
178+
testServiceClient: nil
163179
)
164180
return try await body(testServer)
165181
}
@@ -205,10 +221,38 @@ public struct TemporalTestServer: Sendable {
205221
_ body: (borrowing TemporalTestServer) async throws -> Void
206222
) async throws {
207223
try await BridgeTestServer.withBridgeTestServer { bridgeTestServer, target in
208-
let testServer = TemporalTestServer(
209-
serverTarget: target
224+
let parts = target.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
225+
guard let host = parts.first.map(String.init),
226+
parts.count > 1,
227+
let port = Int(parts[1])
228+
else {
229+
fatalError("Invalid host and port received from test server.")
230+
}
231+
232+
let grpcClient = GRPCClient(
233+
transport: try .http2NIOPosix(
234+
target: .dns(host: host, port: port),
235+
transportSecurity: .plaintext,
236+
config: .defaults,
237+
resolverRegistry: .defaults,
238+
serviceConfig: .init(),
239+
eventLoopGroup: .singletonMultiThreadedEventLoopGroup
240+
)
210241
)
211-
return try await body(testServer)
242+
243+
return try await withThrowingTaskGroup(of: Void.self) { group in
244+
group.addTask {
245+
try await grpcClient.run()
246+
}
247+
248+
let testServer = TemporalTestServer(
249+
serverTarget: target,
250+
testServiceClient: .init(wrapping: grpcClient)
251+
)
252+
try await body(testServer)
253+
254+
grpcClient.beginGracefulShutdown()
255+
}
212256
}
213257
}
214258

@@ -273,6 +317,14 @@ public struct TemporalTestServer: Sendable {
273317
) async throws -> Result {
274318
let (host, port) = self.hostAndPort()
275319

320+
// Auto-inject the time-skipping interceptor when connected to a
321+
// time-skipping server. It is placed first so it wraps all other
322+
// interceptors, matching the behavior of the C# and Ruby SDKs.
323+
var allInterceptors: [any Temporal.ClientInterceptor] = interceptors
324+
if self.supportsTimeSkipping {
325+
allInterceptors.insert(TimeSkippingClientInterceptor(testServer: self), at: 0)
326+
}
327+
276328
return try await TemporalClient.connect(
277329
transport: .http2NIOPosix(
278330
target: .dns(host: host, port: port),
@@ -284,7 +336,7 @@ public struct TemporalTestServer: Sendable {
284336
),
285337
configuration: .init(
286338
instrumentation: .init(serverHostname: host),
287-
interceptors: interceptors
339+
interceptors: allInterceptors
288340
),
289341
logger: logger,
290342
) { client in
@@ -424,6 +476,98 @@ public struct TemporalTestServer: Sendable {
424476
}
425477
}
426478

479+
// MARK: - Time Control
480+
481+
/// Advances the test server time by the specified duration.
482+
///
483+
/// When connected to a time-skipping test server, this method instructs the server to
484+
/// fast-forward its internal clock by the given duration. This causes any pending timers
485+
/// or scheduled events within that time window to fire immediately.
486+
///
487+
/// - Parameter duration: The amount of time to advance the server clock.
488+
/// - Throws: An error if the server does not support time skipping or the RPC fails.
489+
public func sleep(_ duration: Duration) async throws {
490+
guard let testServiceClient else {
491+
preconditionFailure("sleep(_:) is only supported on time-skipping test servers")
492+
}
493+
_ = try await testServiceClient.unlockTimeSkippingWithSleep(
494+
.with {
495+
$0.duration = .with {
496+
$0.seconds = duration.components.seconds
497+
$0.nanos = Int32(duration.components.attoseconds / 1_000_000_000)
498+
}
499+
}
500+
)
501+
}
502+
503+
/// Returns the current time as known to the test server.
504+
///
505+
/// When connected to a time-skipping test server, this returns the server's internal
506+
/// time, which may differ from wall-clock time due to time skipping.
507+
///
508+
/// - Returns: The current server time.
509+
/// - Throws: An error if the server does not support time skipping or the RPC fails.
510+
public func currentTime() async throws -> Date {
511+
guard let testServiceClient else {
512+
preconditionFailure("currentTime() is only supported on time-skipping test servers")
513+
}
514+
return try await testServiceClient.getCurrentTime(.init()).time.date
515+
}
516+
517+
/// Executes a closure with automatic time skipping temporarily disabled.
518+
///
519+
/// While automatic time skipping is disabled, calls to `handle.result`
520+
/// will not unlock the time-skipping server's clock. Time will only advance through
521+
/// explicit calls to ``sleep(_:)`` or real-time passage.
522+
///
523+
/// This is useful when you need to observe intermediate workflow states without
524+
/// time being advanced automatically.
525+
///
526+
/// - Parameter body: The closure to execute with auto-time-skipping disabled.
527+
/// - Returns: The result of the closure.
528+
/// - Throws: Any error thrown by the closure.
529+
public func withAutoTimeSkippingDisabled<Result: Sendable>(
530+
_ body: () async throws -> Result
531+
) async rethrows -> Result {
532+
try await Self.$autoTimeSkippingDisabled.withValue(true) {
533+
try await body()
534+
}
535+
}
536+
537+
/// Unlocks time skipping for the duration of the given closure, then re-locks it.
538+
///
539+
/// This is used internally by ``TimeSkippingClientInterceptor`` to allow time to
540+
/// advance while waiting for workflow results.
541+
func withTimeSkippingUnlocked<Result: Sendable>(
542+
_ body: () async throws -> Result
543+
) async throws -> Result {
544+
guard let testServiceClient else {
545+
return try await body()
546+
}
547+
548+
_ = try await testServiceClient.unlockTimeSkipping(.init())
549+
550+
var userCodeSucceeded = false
551+
do {
552+
let result = try await body()
553+
userCodeSucceeded = true
554+
_ = try await testServiceClient.lockTimeSkipping(.init())
555+
return result
556+
} catch {
557+
// Re-lock time skipping. If user code failed, swallow lock errors
558+
// to preserve the original error.
559+
do {
560+
_ = try await testServiceClient.lockTimeSkipping(.init())
561+
} catch {
562+
if userCodeSucceeded {
563+
throw error
564+
}
565+
// Swallow lock error when user code already failed
566+
}
567+
throw error
568+
}
569+
}
570+
427571
/// Provides the host and port information from the server target address.
428572
///
429573
/// - Returns: A tuple containing the host string and port number for the test server.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift Temporal SDK open source project
4+
//
5+
// Copyright (c) 2026 Apple Inc. and the Swift Temporal SDK project authors
6+
// Licensed under MIT License
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors
10+
//
11+
// SPDX-License-Identifier: MIT
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
#if canImport(Testing)
16+
import Temporal
17+
18+
/// A client interceptor that automatically unlocks time skipping on the
19+
/// Temporal time-skipping test server when waiting for workflow results.
20+
///
21+
/// When the time-skipping test server is active, timers in workflows only advance
22+
/// when time skipping is unlocked. This interceptor unlocks time skipping around
23+
/// ``fetchWorkflowHistoryEvents`` calls that are waiting for a workflow close event
24+
/// (which is the pattern used by ``UntypedWorkflowHandle/result(followRuns:resultTypes:callOptions:)``).
25+
///
26+
/// This interceptor is auto-injected by ``TemporalTestServer`` when connecting to
27+
/// a time-skipping test server. You do not need to add it manually.
28+
struct TimeSkippingClientInterceptor: Temporal.ClientInterceptor, Sendable {
29+
private let testServer: TemporalTestServer
30+
31+
package init(testServer: TemporalTestServer) {
32+
self.testServer = testServer
33+
}
34+
35+
func makeClientOutboundInterceptor() -> Outbound? {
36+
Outbound(testServer: self.testServer)
37+
}
38+
39+
struct Outbound: Temporal.ClientOutboundInterceptor {
40+
private let testServer: TemporalTestServer
41+
42+
init(testServer: TemporalTestServer) {
43+
self.testServer = testServer
44+
}
45+
46+
func fetchWorkflowHistoryEvents(
47+
input: FetchWorkflowHistoryEventsInput,
48+
next: (FetchWorkflowHistoryEventsInput) async throws -> [Api.History.V1.HistoryEvent]
49+
) async throws -> [Api.History.V1.HistoryEvent] {
50+
// Only unlock time skipping when waiting for a close event, which
51+
// is the pattern used by result() to poll for workflow completion.
52+
guard input.waitNewEvent && input.eventFilterType == .closeEvent else {
53+
return try await next(input)
54+
}
55+
56+
guard testServer.isAutoTimeSkippingEnabled else {
57+
return try await next(input)
58+
}
59+
60+
return try await testServer.withTimeSkippingUnlocked {
61+
try await next(input)
62+
}
63+
}
64+
}
65+
}
66+
#endif

Tests/TemporalTests/TestHelpers/WorkerTestHelper.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,14 @@ import Testing
2121

2222
import protocol GRPCCore.ClientTransport
2323

24-
@Suite(.temporalTestServer, .temporalTimeSkippingTestServer, .timeLimit(.minutes(1)))
24+
// Serialized: Makes it easier to understand which test fails and doesn't overwhelm the Temporal test server
25+
// TimeLimit: Ensures that nothing gets stuck forever
26+
@Suite(
27+
.temporalTestServer,
28+
.temporalTimeSkippingTestServer,
29+
.serialized,
30+
.timeLimit(.minutes(2))
31+
)
2532
enum TestServerDependentTests {}
2633

2734
func withTestClient<Result: Sendable>(
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift Temporal SDK open source project
4+
//
5+
// Copyright (c) 2026 Apple Inc. and the Swift Temporal SDK project authors
6+
// Licensed under MIT License
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of Swift Temporal SDK project authors
10+
//
11+
// SPDX-License-Identifier: MIT
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import Foundation
16+
import Temporal
17+
import TemporalTestKit
18+
import Testing
19+
20+
extension TestServerDependentTests {
21+
@Suite(.tags(.workflowTests))
22+
struct TimeSkippingInterceptorTests {
23+
@Workflow
24+
final class LongTimerWorkflow {
25+
func run(input: Void) async throws -> String {
26+
// Sleep for 1 hour in workflow time
27+
try await Workflow.sleep(for: .seconds(3600))
28+
return "completed"
29+
}
30+
}
31+
32+
@Test
33+
func longTimerCompletesQuicklyWithTimeSkipping() async throws {
34+
let start = ContinuousClock.now
35+
try await withTimeSkippingTestWorkerAndClient(
36+
workflows: [LongTimerWorkflow.self]
37+
) { taskQueue, client in
38+
let handle = try await client.startWorkflow(
39+
type: LongTimerWorkflow.self,
40+
options: .init(id: "time-skip-\(UUID())", taskQueue: taskQueue)
41+
)
42+
let result = try await handle.result()
43+
#expect(result == "completed")
44+
}
45+
let elapsed = ContinuousClock.now - start
46+
// The 1-hour timer should complete well under 60 seconds of wall clock time
47+
#expect(elapsed < .seconds(60))
48+
}
49+
50+
@Test
51+
func currentTimeReturnsValue() async throws {
52+
let testServer = TemporalTestServer.timeSkippingTestServer!
53+
let time = try await testServer.currentTime()
54+
// The time should be a reasonable date (after 2020)
55+
let referenceDate = Date(timeIntervalSince1970: 1_577_836_800) // 2020-01-01
56+
#expect(time > referenceDate)
57+
}
58+
59+
@Test
60+
func sleepAdvancesTime() async throws {
61+
let testServer = TemporalTestServer.timeSkippingTestServer!
62+
let timeBefore = try await testServer.currentTime()
63+
try await testServer.sleep(.seconds(3600))
64+
let timeAfter = try await testServer.currentTime()
65+
// Time should have advanced by at least 1 hour
66+
let elapsed = timeAfter.timeIntervalSince(timeBefore)
67+
#expect(elapsed >= 3599)
68+
}
69+
}
70+
}

0 commit comments

Comments
 (0)