Compare commits

..

4 Commits

Author SHA1 Message Date
Flávio Caetano e6dd6889bb Stop running UI tests in CI 2019-04-12 19:54:32 -03:00
Flávio Caetano 891d499ee4 Fix improve resource loading verification 2019-04-12 19:41:30 -03:00
Flávio Caetano cc03104a72 Revert "Add verification for ReCaptcha config"
This reverts commit 4e6c022a23.
2019-04-12 17:08:01 -03:00
Flávio Caetano 4e6c022a23 Add verification for ReCaptcha config
This will verify if ReCaptchaKey and ReCaptchaDomain are correctly configured and the JS lib was able to load correctly
2019-04-10 19:26:53 -03:00
15 changed files with 243 additions and 191 deletions

View File

@ -1 +1 @@
binary "https://raw.github.com/TouchInstinct/CarthageBinaries/master/RxSwift/RxSwift.json" github "ReactiveX/RxSwift" ~> 4.3

View File

@ -1 +1 @@
binary "https://raw.github.com/TouchInstinct/CarthageBinaries/master/RxSwift/RxSwift.json" "4.5.0" github "ReactiveX/RxSwift" "4.4.0"

View File

@ -1,7 +1,7 @@
PODS: PODS:
- AppSwizzle (1.3.1) - AppSwizzle (1.3.1)
- ReCaptcha/Core (1.4.1) - ReCaptcha/Core (1.4.2)
- ReCaptcha/RxSwift (1.4.1): - ReCaptcha/RxSwift (1.4.2):
- ReCaptcha/Core - ReCaptcha/Core
- RxSwift (~> 4.3) - RxSwift (~> 4.3)
- RxAtomic (4.4.0) - RxAtomic (4.4.0)
@ -36,7 +36,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
AppSwizzle: db36e436f56110d93e5ae0147683435df593cabc AppSwizzle: db36e436f56110d93e5ae0147683435df593cabc
ReCaptcha: 520a707a38dfbb1e5de812aa3c041df60bd31827 ReCaptcha: 9a0e1c02a9db9dface31cca63515e28fc3ed6ba8
RxAtomic: eacf60db868c96bfd63320e28619fe29c179656f RxAtomic: eacf60db868c96bfd63320e28619fe29c179656f
RxBlocking: 138ad53217434444d6eeeb4fb406a45431d92e31 RxBlocking: 138ad53217434444d6eeeb4fb406a45431d92e31
RxCocoa: df63ebf7b9a70d6b4eeea407ed5dd4efc8979749 RxCocoa: df63ebf7b9a70d6b4eeea407ed5dd4efc8979749

View File

@ -54,7 +54,7 @@
</BuildableReference> </BuildableReference>
</TestableReference> </TestableReference>
<TestableReference <TestableReference
skipped = "NO"> skipped = "YES">
<BuildableReference <BuildableReference
BuildableIdentifier = "primary" BuildableIdentifier = "primary"
BlueprintIdentifier = "F28FAC9B200E425600E14987" BlueprintIdentifier = "F28FAC9B200E425600E14987"

View File

@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "0910"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "NO"
buildForArchiving = "NO"
buildForAnalyzing = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "F28FAC9B200E425600E14987"
BuildableName = "ReCaptcha_UITests.xctest"
BlueprintName = "ReCaptcha_UITests"
ReferencedContainer = "container:ReCaptcha.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "F28FAC9B200E425600E14987"
BuildableName = "ReCaptcha_UITests.xctest"
BlueprintName = "ReCaptcha_UITests"
ReferencedContainer = "container:ReCaptcha.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<AdditionalOptions>
</AdditionalOptions>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "F28FAC9B200E425600E14987"
BuildableName = "ReCaptcha_UITests.xctest"
BlueprintName = "ReCaptcha_UITests"
ReferencedContainer = "container:ReCaptcha.xcodeproj">
</BuildableReference>
</MacroExpansion>
<AdditionalOptions>
</AdditionalOptions>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "F28FAC9B200E425600E14987"
BuildableName = "ReCaptcha_UITests.xctest"
BlueprintName = "ReCaptcha_UITests"
ReferencedContainer = "container:ReCaptcha.xcodeproj">
</BuildableReference>
</MacroExpansion>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -164,4 +164,23 @@ class ReCaptchaDecoder__Tests: XCTestCase {
// Check // Check
XCTAssertEqual(result, .didLoad) XCTAssertEqual(result, .didLoad)
} }
func test__Decode__Error_Setup_Failed() {
let exp = expectation(description: "send error")
var result: Result?
assertResult = { res in
result = res
exp.fulfill()
}
// Send
let message = MockMessage(message: ["error": 27])
decoder.send(message: message)
waitForExpectations(timeout: 1)
// Check
XCTAssertEqual(result, .error(.failedSetup))
}
} }

View File

@ -15,7 +15,8 @@ extension ReCaptchaError: Equatable {
case (.htmlLoadError, .htmlLoadError), case (.htmlLoadError, .htmlLoadError),
(.apiKeyNotFound, .apiKeyNotFound), (.apiKeyNotFound, .apiKeyNotFound),
(.baseURLNotFound, .baseURLNotFound), (.baseURLNotFound, .baseURLNotFound),
(.wrongMessageFormat, .wrongMessageFormat): (.wrongMessageFormat, .wrongMessageFormat),
(.failedSetup, .failedSetup):
return true return true
case (.unexpected(let lhe as NSError), .unexpected(let rhe as NSError)): case (.unexpected(let lhe as NSError), .unexpected(let rhe as NSError)):
return lhe == rhe return lhe == rhe
@ -25,11 +26,12 @@ extension ReCaptchaError: Equatable {
} }
static func random() -> ReCaptchaError { static func random() -> ReCaptchaError {
switch arc4random_uniform(4) { switch arc4random_uniform(5) {
case 0: return .htmlLoadError case 0: return .htmlLoadError
case 1: return .apiKeyNotFound case 1: return .apiKeyNotFound
case 2: return .baseURLNotFound case 2: return .baseURLNotFound
case 3: return .wrongMessageFormat case 3: return .wrongMessageFormat
case 4: return .failedSetup
default: return .unexpected(NSError()) default: return .unexpected(NSError())
} }
} }

View File

@ -6,11 +6,11 @@
var endpoint = "${endpoint}"; var endpoint = "${endpoint}";
var shouldFail = ${shouldFail}; var shouldFail = ${shouldFail};
var post = function(value) { var post = (value) => {
window.webkit.messageHandlers.recaptcha.postMessage(value); window.webkit.messageHandlers.recaptcha.postMessage(value);
}; };
var execute = function() { var execute = () => {
if (shouldFail) { if (shouldFail) {
post("error"); post("error");
} }
@ -19,10 +19,12 @@
} }
}; };
var reset = function() { var reset = () => {
shouldFail = false; shouldFail = false;
post({action: "didLoad"}); post({ action: "didLoad" });
}; };
post({ action: "didLoad" });
</script> </script>
</head> </head>
<body> <body>

View File

@ -2,61 +2,81 @@
<head> <head>
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<script type="text/javascript"> <script type="text/javascript">
var post = function(value) { const post = (value) => window.webkit.messageHandlers.recaptcha.postMessage(value);
window.webkit.messageHandlers.recaptcha.postMessage(value); console.log = (message) => post({ log: message });
let observers = []
const observeDOM = (element, completion) => {
const obs = new MutationObserver(completion);
obs.observe(element, {
attributes: true,
childList: true,
subtree: true,
attributeFilter: ['style'],
});
observers.push(obs);
}; };
console.log = function(message) { const clearObservers = () => {
post({log: message}); observers.forEach(o => o.disconnect());
observers = [];
}; };
var showReCaptcha = function() { const execute = () => {
console.log("showReCaptcha"); console.log('executing');
post({action: "showReCaptcha"});
};
var observeDOM = function(element, completion) {
new MutationObserver(function(mutations) {
mutations.forEach(function(mutationRecord) {
completion();
});
})
.observe(element, {attributes: true, attributeFilter: ['style']})
};
var execute = function() {
console.log("executing");
// Removes ReCaptcha dismissal when clicking outside div area // Removes ReCaptcha dismissal when clicking outside div area
try { try {
document.getElementsByTagName("div")[4].outerHTML = "" document.getElementsByTagName('div')[4].outerHTML = ''
} }
catch(e) { catch(e) {
} }
// Listens to changes on the div element that presents the ReCaptcha challenge try {
observeDOM(document.getElementsByTagName("div")[3], showReCaptcha); // Listens to changes on the div element that presents the ReCaptcha challenge
observeDOM(document.getElementsByTagName('div')[3], () => {
post({ action: 'showReCaptcha' });
});
} catch(e) {
post({ error: 27 })
}
grecaptcha.execute(); grecaptcha.execute();
}; };
var onSubmit = function(token) { const reset = () => {
console.log(token); console.log('resetting');
post({token: token}); grecaptcha.reset();
}; grecaptcha.ready(() => post({ action: 'didLoad' }));
};
var onloadCallback = function() { var onloadCallback = () => {
console.log("did load");
grecaptcha.render('submit', { grecaptcha.render('submit', {
'sitekey' : '${apiKey}', sitekey: '${apiKey}',
'callback' : onSubmit, callback: (token) => {
'size': 'invisible' console.log(token);
post({ token });
clearObservers();
},
size: 'invisible'
}); });
};
var reset = function() { grecaptcha.ready(() => {
console.log("resetting"); observeDOM(document.getElementById('body'), mut => {
grecaptcha.reset(); const success = !!mut.find(
({ addedNodes }) =>
Array.from(addedNodes.values())
.find(({ nodeName, name }) =>
nodeName === 'IFRAME' && !!name
)
);
if (success) {
post({ action: 'didLoad' });
}
});
});
}; };
</script> </script>
</head> </head>

View File

@ -13,8 +13,6 @@ import WebKit
/** /**
*/ */
public class ReCaptcha { public class ReCaptcha {
public typealias BoolParameterClosure = (Bool) -> ()
fileprivate struct Constants { fileprivate struct Constants {
struct InfoDictKeys { struct InfoDictKeys {
static let APIKey = "ReCaptchaKey" static let APIKey = "ReCaptchaKey"
@ -103,12 +101,6 @@ public class ReCaptcha {
} }
} }
/// Callback for WebView loading state changing
public var onLoadingChanged: BoolParameterClosure? {
get { return manager.onLoadingChanged }
set { manager.onLoadingChanged = newValue }
}
/// The worker that handles webview events and communication /// The worker that handles webview events and communication
let manager: ReCaptchaWebViewManager let manager: ReCaptchaWebViewManager

View File

@ -89,6 +89,12 @@ fileprivate extension ReCaptchaDecoder.Result {
if let token = response["token"] as? String { if let token = response["token"] as? String {
return .token(token) return .token(token)
} }
else if let message = response["log"] as? String {
return .log(message)
}
else if let message = response["error"] as? Int {
return .error(.failedSetup)
}
if let action = response["action"] as? String { if let action = response["action"] as? String {
switch action { switch action {

View File

@ -25,6 +25,8 @@ public enum ReCaptchaError: Error, CustomStringConvertible {
/// Received an unexpected message from javascript /// Received an unexpected message from javascript
case wrongMessageFormat case wrongMessageFormat
/// ReCaptcha setup failed
case failedSetup
/// A human-readable description for each error /// A human-readable description for each error
public var description: String { public var description: String {
@ -43,6 +45,14 @@ public enum ReCaptchaError: Error, CustomStringConvertible {
case .wrongMessageFormat: case .wrongMessageFormat:
return "Unexpected message from javascript" return "Unexpected message from javascript"
case .failedSetup:
// swiftlint:disable line_length
return """
WARNING! ReCaptcha wasn't successfully configured. Please double check your ReCaptchaKey and ReCaptchaDomain.
Also check that you're using ReCaptcha's **SITE KEY** for client side integration.
"""
// swiftlint:enable line_length
} }
} }
} }

View File

@ -13,18 +13,15 @@ import WebKit
/** Handles comunications with the webview containing the ReCaptcha challenge. /** Handles comunications with the webview containing the ReCaptcha challenge.
*/ */
internal class ReCaptchaWebViewManager { internal class ReCaptchaWebViewManager {
enum JSCommand: String {
case execute = "execute();"
case reset = "reset();"
}
fileprivate struct Constants { fileprivate struct Constants {
static let ExecuteJSCommand = "execute();" static let ExecuteJSCommand = "execute();"
static let ResetCommand = "reset();" static let ResetCommand = "reset();"
static let BotUserAgent = "Googlebot/2.1" static let BotUserAgent = "Googlebot/2.1"
static let NumberOfDivsCommand = "document.getElementsByTagName(\"div\").length"
static let MinNumberOfDivs = 5
static let NumberOfDivsFinishedLoadingAttempts = 10
// A page doesn't have enough time to load on old devices
static let NumberOfDivsLoadingDelay = 0.5
} }
#if DEBUG #if DEBUG
@ -44,9 +41,6 @@ internal class ReCaptchaWebViewManager {
public var shouldSkipForTests = false public var shouldSkipForTests = false
#endif #endif
/// Callback for loading state changing
var onLoadingChanged: ReCaptcha.BoolParameterClosure?
/// Sends the result message /// Sends the result message
var completion: ((ReCaptchaResult) -> Void)? var completion: ((ReCaptchaResult) -> Void)?
@ -63,24 +57,11 @@ internal class ReCaptchaWebViewManager {
fileprivate var decoder: ReCaptchaDecoder! fileprivate var decoder: ReCaptchaDecoder!
/// Indicates if the script has already been loaded by the `webView` /// Indicates if the script has already been loaded by the `webView`
fileprivate var didFinishLoading = false { // webView.isLoading does not work for WKWebview.loadHTMLString fileprivate var didFinishLoading = false
didSet {
if didFinishLoading && completion != nil {
// User has requested for validation
// A small delay is necessary to allow JS to wrap its operations and avoid errors.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
self?.execute()
}
}
}
}
/// The observer for `.UIWindowDidBecomeVisible` /// The observer for `.UIWindowDidBecomeVisible`
fileprivate var observer: NSObjectProtocol? fileprivate var observer: NSObjectProtocol?
/// The observer for `\WKWebView.estimatedProgress`
fileprivate var loadingObservation: NSKeyValueObservation?
/// The endpoint url being used /// The endpoint url being used
fileprivate var endpoint: String fileprivate var endpoint: String
@ -93,9 +74,6 @@ internal class ReCaptchaWebViewManager {
webview.accessibilityIdentifier = "webview" webview.accessibilityIdentifier = "webview"
webview.accessibilityTraits = UIAccessibilityTraits.link webview.accessibilityTraits = UIAccessibilityTraits.link
webview.isHidden = true webview.isHidden = true
self.loadingObservation = webview.observe(\.estimatedProgress, options: [.new]) { [weak self] _, change in
self?.didFinishLoading = change.newValue == 1
}
return webview return webview
}() }()
@ -109,7 +87,6 @@ internal class ReCaptchaWebViewManager {
*/ */
init(html: String, apiKey: String, baseURL: URL, endpoint: String) { init(html: String, apiKey: String, baseURL: URL, endpoint: String) {
self.endpoint = endpoint self.endpoint = endpoint
self.decoder = ReCaptchaDecoder { [weak self] result in self.decoder = ReCaptchaDecoder { [weak self] result in
self?.handle(result: result) self?.handle(result: result)
} }
@ -137,7 +114,6 @@ internal class ReCaptchaWebViewManager {
Starts the challenge validation Starts the challenge validation
*/ */
func validate(on view: UIView) { func validate(on view: UIView) {
onLoadingChanged?(true)
#if DEBUG #if DEBUG
guard !shouldSkipForTests else { guard !shouldSkipForTests else {
completion?(.token("")) completion?(.token(""))
@ -147,7 +123,7 @@ internal class ReCaptchaWebViewManager {
webView.isHidden = false webView.isHidden = false
view.addSubview(webView) view.addSubview(webView)
execute() executeJS(command: .execute)
} }
@ -162,14 +138,9 @@ internal class ReCaptchaWebViewManager {
The reset is achieved by calling `grecaptcha.reset()` on the JS API. The reset is achieved by calling `grecaptcha.reset()` on the JS API.
*/ */
func reset() { func reset() {
didFinishLoading = false
configureWebViewDispatchToken = UUID() configureWebViewDispatchToken = UUID()
executeJS(command: .reset)
webView.evaluateJavaScript(Constants.ResetCommand) { [weak self] _, error in didFinishLoading = false
if let error = error {
self?.decoder.send(error: .unexpected(error))
}
}
} }
} }
@ -178,77 +149,6 @@ internal class ReCaptchaWebViewManager {
/** Private methods for ReCaptchaWebViewManager /** Private methods for ReCaptchaWebViewManager
*/ */
fileprivate extension ReCaptchaWebViewManager { fileprivate extension ReCaptchaWebViewManager {
/** Executes the JS command that loads the ReCaptcha challenge after a page finished loading.
This method has no effect if the webview hasn't finished loading.
*/
func execute() {
guard didFinishLoading else {
// Hasn't finished loading the HTML yet
return
}
evaluateExecuteWhenLoadingFinished(count: 0)
}
/**
- parameter count: Number of checks of number of divs
Executes the JS command that returns number of divs.
*/
func evaluateExecuteWhenLoadingFinished(count: Int) {
webView.evaluateJavaScript(Constants.NumberOfDivsCommand) { [weak self] (result, error) -> Void in
if let error = error {
self?.decoder.send(error: .unexpected(error))
self?.onLoadingChanged?(false)
} else {
self?.handleNumberOfDivs(result: result, count: count)
}
}
}
/**
- parameters:
- result: Result of number of divs command evaluation
- count: Number of checks of divs count
Handles number of divs command result.
*/
func handleNumberOfDivs(result: Any?, count: Int) {
if let result = result as? Int, result >= Constants.MinNumberOfDivs {
evaluateExecute()
} else {
handleInvalidNumberOfDivsResult(count: count)
}
}
/**
- parameter count: Number of checks of number of divs
Handles invalid number of divs.
*/
func handleInvalidNumberOfDivsResult(count: Int) {
if count < Constants.NumberOfDivsFinishedLoadingAttempts {
DispatchQueue.main.asyncAfter(deadline: .now() + Constants.NumberOfDivsLoadingDelay) { [weak self] in
self?.evaluateExecuteWhenLoadingFinished(count: count + 1)
}
} else {
decoder.send(error: .htmlLoadError)
onLoadingChanged?(false)
}
}
/**
Executes the JS command that loads the ReCaptcha challenge.
*/
func evaluateExecute() {
webView.evaluateJavaScript(Constants.ExecuteJSCommand) { [weak self] _, error in
if let error = error {
self?.decoder.send(error: .unexpected(error))
}
self?.onLoadingChanged?(false)
}
}
/** /**
- returns: An instance of `WKWebViewConfiguration` - returns: An instance of `WKWebViewConfiguration`
@ -290,12 +190,14 @@ fileprivate extension ReCaptchaWebViewManager {
} }
case .didLoad: case .didLoad:
// For testing purposes
didFinishLoading = true didFinishLoading = true
if completion != nil {
executeJS(command: .execute)
}
case .log(let message): case .log(let message):
#if DEBUG #if DEBUG
print("[JS LOG]:", message) print("[JS LOG]:", message)
#endif #endif
} }
} }
@ -316,4 +218,24 @@ fileprivate extension ReCaptchaWebViewManager {
NotificationCenter.default.removeObserver(observer) NotificationCenter.default.removeObserver(observer)
} }
} }
/**
- parameters:
- command: The JavaScript command to be executed
Executes the JS command that loads the ReCaptcha challenge. This method has no effect if the webview hasn't
finished loading.
*/
func executeJS(command: JSCommand) {
guard didFinishLoading else {
// Hasn't finished loading all the resources
return
}
webView.evaluateJavaScript(command.rawValue) { [weak self] _, error in
if let error = error {
self?.decoder.send(error: .unexpected(error))
}
}
}
} }

View File

@ -9,10 +9,6 @@
import RxSwift import RxSwift
import UIKit import UIKit
public enum ReCaptchaRxError: Error {
case baseWasReleased
}
/// Makes ReCaptcha compatible with RxSwift extensions /// Makes ReCaptcha compatible with RxSwift extensions
extension ReCaptcha: ReactiveCompatible {} extension ReCaptcha: ReactiveCompatible {}
@ -68,20 +64,4 @@ public extension Reactive where Base: ReCaptcha {
base?.reset() base?.reset()
} }
} }
/**
Observable of loading state
(will not work if someone changes onLoadingChanged variable; current onLodinglChanged will not work after subscription)
*/
var loadingObservable: Observable<Bool> {
return .create { [weak base] observer -> Disposable in
guard let base = base else {
observer.onError(ReCaptchaRxError.baseWasReleased)
return Disposables.create()
}
base.onLoadingChanged = { observer.onNext($0) }
return Disposables.create { [weak base] in base?.onLoadingChanged = nil }
}
}
} }

View File

@ -10,10 +10,10 @@ default_platform :ios
platform :ios do platform :ios do
skip_docs skip_docs
devices = ["iPhone XR (~> 12)"] devices = ["iPhone X (~> 12)"]
devices << "iPhone X (~> 11)" if !Helper.is_ci? # devices << "iPhone X (~> 11)" if !Helper.is_ci?
devices << "iPhone 7 (~> 10)" if !Helper.is_ci? # devices << "iPhone 7 (~> 10)" if !Helper.is_ci?
devices << "iPhone 6s (~> 9)" if !Helper.is_ci? # devices << "iPhone 6s (~> 9)" if !Helper.is_ci?
desc "Runs the following lanes:\n- test\n- pod_lint\n- carthage_lint" desc "Runs the following lanes:\n- test\n- pod_lint\n- carthage_lint"
lane :ci do lane :ci do
@ -57,12 +57,21 @@ platform :ios do
code_coverage: true, code_coverage: true,
) )
if is_ci if Helper.is_ci?
codecov( codecov(
project_name: 'ReCaptcha', project_name: 'ReCaptcha',
use_xcodeplist: true, use_xcodeplist: true,
) )
else else
puts "Running UI Tests"
scan(
test_without_building: true,
devices: self.select_similar_simulator(devices),
scheme: "ReCaptcha_UITests",
workspace: "Example/ReCaptcha.xcworkspace",
code_coverage: true,
)
puts "Not CI: Skipping coverage files upload" puts "Not CI: Skipping coverage files upload"
end end
end end