Skip to content

Commit 7bab7bd

Browse files
Merge pull request #2485 from DataDog/mariedm/rum-11034-fo-integration-tests
RUM-11034 FO Add integration tests Co-authored-by: mariedm <marie.denis@datadoghq.com>
2 parents 6649478 + 9351f1e commit 7bab7bd

9 files changed

Lines changed: 820 additions & 2 deletions

File tree

Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2019-Present Datadog, Inc.
5+
*/
6+
7+
import HTTPServerMock
8+
import TestUtilities
9+
import XCTest
10+
import DatadogInternal
11+
12+
private extension ExampleApplication {
13+
func tapStartLoginFlow() {
14+
buttons["Start Login"].tap()
15+
}
16+
17+
func tapSucceedLoginFlow() {
18+
buttons["Succeed Login"].tap()
19+
}
20+
21+
func tapFailLoginFlow() {
22+
buttons["Fail Login"].tap()
23+
}
24+
25+
func tapStartConcurrentOperations() {
26+
buttons["Start Photo"].tap()
27+
}
28+
29+
func tapSucceedConcurrentOperations() {
30+
buttons["Succeed Photo"].tap()
31+
}
32+
33+
func tapFailConcurrentOperations() {
34+
buttons["Fail Photo"].tap()
35+
}
36+
37+
func tapPushNextScreen() {
38+
buttons["Push Next Screen"].tap()
39+
}
40+
41+
func tapSucceedLoginOnNextView() {
42+
buttons["Succeed Login on Next View"].tap()
43+
}
44+
}
45+
46+
class RUMFeatureOperationsScenarioTests: IntegrationTests, RUMCommonAsserts {
47+
48+
// MARK: - Basic Operation Test
49+
50+
func testBasicFeatureOperation() throws {
51+
let rumServerSession = server.obtainUniqueRecordingSession()
52+
let app = ExampleApplication()
53+
app.launchWith(
54+
testScenarioClassName: "RUMFeatureOperationsScenario",
55+
serverConfiguration: HTTPServerMockConfiguration(
56+
rumEndpoint: rumServerSession.recordingURL
57+
)
58+
)
59+
60+
let screen = ExampleApplication()
61+
62+
// Test basic login flow (no operation key)
63+
screen.tapStartLoginFlow()
64+
screen.tapSucceedLoginFlow()
65+
66+
try app.endRUMSession()
67+
68+
let recordedRUMRequests = try rumServerSession.pullRecordedRequests(timeout: dataDeliveryTimeout) { requests in
69+
try RUMSessionMatcher.singleSession(from: requests)?.hasEnded() ?? false
70+
}
71+
72+
assertRUM(requests: recordedRUMRequests)
73+
let session = try XCTUnwrap(RUMSessionMatcher.singleSession(from: recordedRUMRequests))
74+
75+
// Verify login flow vital events
76+
let vitalEvents = session.vitalEvents
77+
let loginStartEvents = vitalEvents.filter { $0.vital.name == "login_flow" && $0.vital.stepType == .start }
78+
let loginSucceedEvents = vitalEvents.filter { $0.vital.name == "login_flow" && $0.vital.stepType == .end && $0.vital.failureReason == nil }
79+
80+
XCTAssertEqual(loginStartEvents.count, 1, "Should have one login flow start event")
81+
XCTAssertEqual(loginSucceedEvents.count, 1, "Should have one login flow succeed event")
82+
83+
// Verify no operation key for basic operation
84+
for event in vitalEvents {
85+
XCTAssertNil(event.vital.operationKey, "Basic operation should not have operation key")
86+
}
87+
}
88+
89+
// MARK: - Failure Scenarios Test
90+
91+
func testFeatureOperationWithFailure() throws {
92+
let rumServerSession = server.obtainUniqueRecordingSession()
93+
let app = ExampleApplication()
94+
app.launchWith(
95+
testScenarioClassName: "RUMFeatureOperationsScenario",
96+
serverConfiguration: HTTPServerMockConfiguration(
97+
rumEndpoint: rumServerSession.recordingURL
98+
)
99+
)
100+
101+
let screen = ExampleApplication()
102+
103+
// Test different failure scenarios
104+
screen.tapStartLoginFlow()
105+
screen.tapFailLoginFlow()
106+
107+
try app.endRUMSession()
108+
109+
let recordedRUMRequests = try rumServerSession.pullRecordedRequests(timeout: dataDeliveryTimeout) { requests in
110+
try RUMSessionMatcher.singleSession(from: requests)?.hasEnded() ?? false
111+
}
112+
113+
assertRUM(requests: recordedRUMRequests)
114+
let session = try XCTUnwrap(RUMSessionMatcher.singleSession(from: recordedRUMRequests))
115+
116+
// Verify error vital events
117+
let vitalEvents = session.vitalEvents
118+
let loginStartEvents = vitalEvents.filter { $0.vital.name == "login_flow" && $0.vital.stepType == .start }
119+
let loginFailEvents = vitalEvents.filter { $0.vital.name == "login_flow" && $0.vital.stepType == .end && $0.vital.failureReason == .error }
120+
121+
XCTAssertEqual(loginStartEvents.count, 1, "Should have one login flow start event")
122+
XCTAssertEqual(loginFailEvents.count, 1, "Should have one login flow fail event")
123+
124+
// Verify failure reason
125+
let failEvent = try XCTUnwrap(loginFailEvents.first)
126+
XCTAssertEqual(failEvent.vital.failureReason, .error, "Should have correct failure reason")
127+
}
128+
129+
// MARK: - Multiple Operation Instances Test
130+
131+
func testParallelFeatureOperations() throws {
132+
let rumServerSession = server.obtainUniqueRecordingSession()
133+
let app = ExampleApplication()
134+
app.launchWith(
135+
testScenarioClassName: "RUMFeatureOperationsScenario",
136+
serverConfiguration: HTTPServerMockConfiguration(
137+
rumEndpoint: rumServerSession.recordingURL
138+
)
139+
)
140+
141+
let screen = ExampleApplication()
142+
143+
// Test parallel photo uploads with different keys
144+
screen.tapStartConcurrentOperations()
145+
screen.tapSucceedConcurrentOperations()
146+
147+
try app.endRUMSession()
148+
149+
let recordedRUMRequests = try rumServerSession.pullRecordedRequests(timeout: dataDeliveryTimeout) { requests in
150+
try RUMSessionMatcher.singleSession(from: requests)?.hasEnded() ?? false
151+
}
152+
153+
assertRUM(requests: recordedRUMRequests)
154+
let session = try XCTUnwrap(RUMSessionMatcher.singleSession(from: recordedRUMRequests))
155+
156+
// Verify parallel operations vital events
157+
let vitalEvents = session.vitalEvents
158+
let photoUploadStartEvents = vitalEvents.filter { $0.vital.name == "photo_upload" && $0.vital.stepType == .start }
159+
let photoUploadSucceedEvents = vitalEvents.filter { $0.vital.name == "photo_upload" && $0.vital.stepType == .end && $0.vital.failureReason == nil }
160+
161+
XCTAssertEqual(photoUploadStartEvents.count, 3, "Should have three photo upload start events")
162+
XCTAssertEqual(photoUploadSucceedEvents.count, 3, "Should have three photo upload succeed events")
163+
164+
// Verify operation keys for parallel instances
165+
let operationKeys = Set(vitalEvents.compactMap { $0.vital.operationKey })
166+
let expectedKeys = Set(["photo1", "photo2", "photo3"])
167+
XCTAssertEqual(operationKeys, expectedKeys, "Parallel operations should have the correct operation keys")
168+
}
169+
170+
func testParallelFeatureOperationsWithFailure() throws {
171+
let rumServerSession = server.obtainUniqueRecordingSession()
172+
let app = ExampleApplication()
173+
app.launchWith(
174+
testScenarioClassName: "RUMFeatureOperationsScenario",
175+
serverConfiguration: HTTPServerMockConfiguration(
176+
rumEndpoint: rumServerSession.recordingURL
177+
)
178+
)
179+
180+
let screen = ExampleApplication()
181+
182+
// Test parallel photo uploads with different keys
183+
screen.tapStartConcurrentOperations()
184+
screen.tapFailConcurrentOperations()
185+
186+
try app.endRUMSession()
187+
188+
let recordedRUMRequests = try rumServerSession.pullRecordedRequests(timeout: dataDeliveryTimeout) { requests in
189+
try RUMSessionMatcher.singleSession(from: requests)?.hasEnded() ?? false
190+
}
191+
192+
assertRUM(requests: recordedRUMRequests)
193+
let session = try XCTUnwrap(RUMSessionMatcher.singleSession(from: recordedRUMRequests))
194+
195+
// Verify parallel operations vital events
196+
let vitalEvents = session.vitalEvents
197+
let startEvents = vitalEvents.filter { $0.vital.name == "photo_upload" && $0.vital.stepType == .start }
198+
let failureEvents = vitalEvents.filter {
199+
$0.vital.name == "photo_upload" &&
200+
$0.vital.stepType == .end &&
201+
$0.vital.failureReason != nil
202+
}
203+
204+
XCTAssertEqual(startEvents.count, 3, "Should have three photo upload start events")
205+
XCTAssertEqual(failureEvents.count, 3, "Should have three photo upload failure events")
206+
207+
let expectedFailures = [
208+
("photo1", RUMVitalEvent.Vital.FailureReason.error),
209+
("photo2", RUMVitalEvent.Vital.FailureReason.abandoned),
210+
("photo3", RUMVitalEvent.Vital.FailureReason.other)
211+
]
212+
213+
zip(failureEvents.sorted { $0.vital.operationKey ?? "" < $1.vital.operationKey ?? "" }, expectedFailures)
214+
.forEach { event, expectedFailure in
215+
XCTAssertEqual(event.vital.operationKey, expectedFailure.0)
216+
XCTAssertEqual(event.vital.failureReason, expectedFailure.1)
217+
}
218+
}
219+
220+
// MARK: - Multiple Views Test
221+
222+
func testFeatureOperationAcrossMultipleViews() throws {
223+
let rumServerSession = server.obtainUniqueRecordingSession()
224+
let app = ExampleApplication()
225+
app.launchWith(
226+
testScenarioClassName: "RUMFeatureOperationsScenario",
227+
serverConfiguration: HTTPServerMockConfiguration(
228+
rumEndpoint: rumServerSession.recordingURL
229+
)
230+
)
231+
232+
let screen = ExampleApplication()
233+
234+
// Start operation in current view
235+
screen.tapStartLoginFlow()
236+
237+
// Navigate to next screen (operation continues)
238+
screen.tapPushNextScreen()
239+
240+
// Complete operation in different view
241+
screen.tapSucceedLoginOnNextView()
242+
243+
screen.swipeDown(velocity: XCUIGestureVelocity.fast)
244+
245+
try app.endRUMSession()
246+
247+
let recordedRUMRequests = try rumServerSession.pullRecordedRequests(timeout: dataDeliveryTimeout) { requests in
248+
try RUMSessionMatcher.singleSession(from: requests)?.hasEnded() ?? false
249+
}
250+
251+
assertRUM(requests: recordedRUMRequests)
252+
let session = try XCTUnwrap(RUMSessionMatcher.singleSession(from: recordedRUMRequests))
253+
print("session: \(session)")
254+
255+
// Verify we have multiple views
256+
XCTAssertGreaterThan(session.views.count, 2, "Should have multiple views")
257+
258+
// Verify vital events capture the correct view context
259+
let vitalEvents = session.vitalEvents
260+
let loginStartEvents = vitalEvents.filter { $0.vital.name == "login_flow" && $0.vital.stepType == .start }
261+
let loginSucceedEvents = vitalEvents.filter { $0.vital.name == "login_flow" && $0.vital.stepType == .end && $0.vital.failureReason == nil }
262+
263+
XCTAssertEqual(loginStartEvents.count, 1, "Should have one login flow start event")
264+
XCTAssertEqual(loginSucceedEvents.count, 1, "Should have one login flow succeed event")
265+
266+
// Verify start event is associated with 1st view
267+
let startEvent = try XCTUnwrap(loginStartEvents.first)
268+
let firstView = session.views[1] // Skip application launch view
269+
XCTAssertEqual(startEvent.view.id, firstView.viewID, "Start event should be associated with the view where it was started")
270+
271+
// Verify second event is associated with 2nd view
272+
let secondEvent = try XCTUnwrap(loginSucceedEvents.first)
273+
let secondView = session.views[2]
274+
XCTAssertEqual(secondEvent.view.id, secondView.viewID, "Second event should be associated with the view where it was started")
275+
}
276+
}

IntegrationTests/IntegrationTests.xcodeproj/project.pbxproj

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@
9191
963F72412DB0FD4C00367298 /* RUMSwiftUIAutoInstrumentationTabbarRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 963F72402DB0FD4B00367298 /* RUMSwiftUIAutoInstrumentationTabbarRootView.swift */; };
9292
964537F02DCBB7EF00C51BF4 /* RUMSwiftUIAutoInstrumentationSingleRootViewScenario.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 964537EF2DCBB7EF00C51BF4 /* RUMSwiftUIAutoInstrumentationSingleRootViewScenario.storyboard */; };
9393
964537F42DCBBAC900C51BF4 /* RUMSwiftUIAutoInstrumentationSingleRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 964537F32DCBBAC800C51BF4 /* RUMSwiftUIAutoInstrumentationSingleRootView.swift */; };
94+
967850462E7C0D9000F2FC52 /* RUMFeatureOperationsScenarioTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 967850452E7C0D9000F2FC52 /* RUMFeatureOperationsScenarioTests.swift */; };
95+
9678504B2E7C223E00F2FC52 /* RUMFeatureOperationsScenario.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 967850492E7C223E00F2FC52 /* RUMFeatureOperationsScenario.storyboard */; };
96+
9678504C2E7C223E00F2FC52 /* RUMFeatureOperationsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9678504A2E7C223E00F2FC52 /* RUMFeatureOperationsViewController.swift */; };
97+
9678504E2E7C600F00F2FC52 /* NextScreenViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9678504D2E7C600900F2FC52 /* NextScreenViewController.swift */; };
9498
96E1E1B32DAFE4C700EF013F /* RUMSwiftUIAutoInstrumentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96E1E1B22DAFE4C500EF013F /* RUMSwiftUIAutoInstrumentationTests.swift */; };
9599
96F48F452DF97240000D6E56 /* RUMSwiftUIAutoInstrumentationActionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96F48F442DF9723F000D6E56 /* RUMSwiftUIAutoInstrumentationActionView.swift */; };
96100
96F48F472DF97318000D6E56 /* RUMSwiftUIAutoInstrumentationActionViewScenario.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 96F48F462DF97318000D6E56 /* RUMSwiftUIAutoInstrumentationActionViewScenario.storyboard */; };
@@ -232,6 +236,10 @@
232236
963F72402DB0FD4B00367298 /* RUMSwiftUIAutoInstrumentationTabbarRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMSwiftUIAutoInstrumentationTabbarRootView.swift; sourceTree = "<group>"; };
233237
964537EF2DCBB7EF00C51BF4 /* RUMSwiftUIAutoInstrumentationSingleRootViewScenario.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = RUMSwiftUIAutoInstrumentationSingleRootViewScenario.storyboard; sourceTree = "<group>"; };
234238
964537F32DCBBAC800C51BF4 /* RUMSwiftUIAutoInstrumentationSingleRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMSwiftUIAutoInstrumentationSingleRootView.swift; sourceTree = "<group>"; };
239+
967850452E7C0D9000F2FC52 /* RUMFeatureOperationsScenarioTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMFeatureOperationsScenarioTests.swift; sourceTree = "<group>"; };
240+
967850492E7C223E00F2FC52 /* RUMFeatureOperationsScenario.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = RUMFeatureOperationsScenario.storyboard; sourceTree = "<group>"; };
241+
9678504A2E7C223E00F2FC52 /* RUMFeatureOperationsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMFeatureOperationsViewController.swift; sourceTree = "<group>"; };
242+
9678504D2E7C600900F2FC52 /* NextScreenViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextScreenViewController.swift; sourceTree = "<group>"; };
235243
96E1E1B22DAFE4C500EF013F /* RUMSwiftUIAutoInstrumentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMSwiftUIAutoInstrumentationTests.swift; sourceTree = "<group>"; };
236244
96F48F442DF9723F000D6E56 /* RUMSwiftUIAutoInstrumentationActionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RUMSwiftUIAutoInstrumentationActionView.swift; sourceTree = "<group>"; };
237245
96F48F462DF97318000D6E56 /* RUMSwiftUIAutoInstrumentationActionViewScenario.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = RUMSwiftUIAutoInstrumentationActionViewScenario.storyboard; sourceTree = "<group>"; };
@@ -467,6 +475,7 @@
467475
isa = PBXGroup;
468476
children = (
469477
61D50C532580EF41006038A3 /* RUMScenarios.swift */,
478+
967850482E7C222400F2FC52 /* Feature Operations */,
470479
96E1E1AD2DAFCC3800EF013F /* SwiftUIAutoInstrumentation */,
471480
9EC2835C26CFF56B00FACF1C /* MobileVitals */,
472481
61337037250F84FD00236D58 /* ManualInstrumentation */,
@@ -670,6 +679,7 @@
670679
D2791EF827170A760046E07A /* RUMSwiftUIManualInstrumentationScenarioTests.swift */,
671680
D22F06D329DAE59A0026CC3C /* RUMStopSessionScenarioTests.swift */,
672681
96E1E1B22DAFE4C500EF013F /* RUMSwiftUIAutoInstrumentationTests.swift */,
682+
967850452E7C0D9000F2FC52 /* RUMFeatureOperationsScenarioTests.swift */,
673683
);
674684
path = RUM;
675685
sourceTree = "<group>";
@@ -683,6 +693,16 @@
683693
path = NavigationControllerAutoInstrumentation;
684694
sourceTree = "<group>";
685695
};
696+
967850482E7C222400F2FC52 /* Feature Operations */ = {
697+
isa = PBXGroup;
698+
children = (
699+
967850492E7C223E00F2FC52 /* RUMFeatureOperationsScenario.storyboard */,
700+
9678504A2E7C223E00F2FC52 /* RUMFeatureOperationsViewController.swift */,
701+
9678504D2E7C600900F2FC52 /* NextScreenViewController.swift */,
702+
);
703+
path = "Feature Operations";
704+
sourceTree = "<group>";
705+
};
686706
96E1E1AD2DAFCC3800EF013F /* SwiftUIAutoInstrumentation */ = {
687707
isa = PBXGroup;
688708
children = (
@@ -914,6 +934,7 @@
914934
D240688627CFA64A00C04F44 /* LaunchScreen.storyboard in Resources */,
915935
61441C0E24616DEC003D8BB8 /* Assets.xcassets in Resources */,
916936
612C13752A9DFEF70086B5D1 /* SRMultipleViewsRecordingScenario.storyboard in Resources */,
937+
9678504B2E7C223E00F2FC52 /* RUMFeatureOperationsScenario.storyboard in Resources */,
917938
6137E649252DD88D00720485 /* RUMModalViewsAutoInstrumentationScenario.storyboard in Resources */,
918939
96F48F472DF97318000D6E56 /* RUMSwiftUIAutoInstrumentationActionViewScenario.storyboard in Resources */,
919940
615AAC36251E353700C89EE9 /* RUMTabBarAutoInstrumentationScenario.storyboard in Resources */,
@@ -1069,6 +1090,7 @@
10691090
61D1C2E42E0EB55700BB0656 /* SceneDelegate.swift in Sources */,
10701091
61163C37252DDD60007DD5BF /* RUMMVSViewController.swift in Sources */,
10711092
61D50C5A2580EFF3006038A3 /* URLSessionScenarios.swift in Sources */,
1093+
9678504C2E7C223E00F2FC52 /* RUMFeatureOperationsViewController.swift in Sources */,
10721094
614CADCE250FCA0200B93D2D /* TestScenarios.swift in Sources */,
10731095
D22F06D029DAE5360026CC3C /* KioskSendEventsViewController.swift in Sources */,
10741096
6111542525C992F8007C84C9 /* CrashReportingScenarios.swift in Sources */,
@@ -1102,6 +1124,7 @@
11021124
61163C3E252E0015007DD5BF /* RUMMVSModalViewController.swift in Sources */,
11031125
617247AF25DA9BEA007085B3 /* CrashReportingObjcHelpers.m in Sources */,
11041126
6193DCE1251B692C009B8011 /* RUMTASTableViewController.swift in Sources */,
1127+
9678504E2E7C600F00F2FC52 /* NextScreenViewController.swift in Sources */,
11051128
6111544825C9A88B007C84C9 /* PersistenceHelper.swift in Sources */,
11061129
9EA95C1C2791C9BE00F6C1F3 /* WebViewTrackingFixtureViewController.swift in Sources */,
11071130
61D50C462580EF19006038A3 /* TracingScenarios.swift in Sources */,
@@ -1145,6 +1168,7 @@
11451168
615AAC07251E217B00C89EE9 /* RUMTapActionScenarioTests.swift in Sources */,
11461169
D263BCB829DB057E00FA0E21 /* RUMStopSessionScenarioTests.swift in Sources */,
11471170
963F6B112DB79B5700F9425A /* ExampleApplication+Extensions.swift in Sources */,
1171+
967850462E7C0D9000F2FC52 /* RUMFeatureOperationsScenarioTests.swift in Sources */,
11481172
);
11491173
runOnlyForDeploymentPostprocessing = 0;
11501174
};

IntegrationTests/IntegrationTests.xcodeproj/xcshareddata/xcschemes/Runner iOS.xcscheme

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,11 @@
236236
value = "RUMSwiftUIAutoInstrumentationActionViewScenario"
237237
isEnabled = "NO">
238238
</EnvironmentVariable>
239+
<EnvironmentVariable
240+
key = "DD_TEST_SCENARIO_CLASS_NAME"
241+
value = "RUMFeatureOperationsScenario"
242+
isEnabled = "NO">
243+
</EnvironmentVariable>
239244
</EnvironmentVariables>
240245
</LaunchAction>
241246
<ProfileAction
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/*
2+
* Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0.
3+
* This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
* Copyright 2019-Present Datadog, Inc.
5+
*/
6+
7+
import UIKit
8+
import DatadogRUM
9+
10+
final class RUMFeatureOperationsNextViewController: UIViewController {
11+
12+
@IBAction func didTapSucceedLoginFlowButton(_ sender: Any) {
13+
rumMonitor.succeedFeatureOperation(
14+
name: Operation.login()
15+
)
16+
}
17+
}

0 commit comments

Comments
 (0)