diff --git a/tests/CordovaLibTests/CDVStatusBarTests.m b/tests/CordovaLibTests/CDVStatusBarTests.m index db670c731..7a48d4c79 100644 --- a/tests/CordovaLibTests/CDVStatusBarTests.m +++ b/tests/CordovaLibTests/CDVStatusBarTests.m @@ -42,6 +42,11 @@ - (void)setUp { self.viewController = self.appDelegate.testViewController; self.plugin = (CDVWebViewEngine *)self.viewController.webViewEngine; + // StatusBar JavaScript APIs use cordova.exec to update native state. The + // shared test helper registers a no-op native plugin that lets each test + // wait until those exec calls have reached native code before asserting the + // view controller's statusBarBackgroundColor. + [self registerCordovaCommandQueueTestPluginForViewController:self.viewController]; } - (void)tearDown { @@ -52,17 +57,13 @@ - (void)tearDown { - (void) testStatusBarBackgroundColorNamedColor { CGFloat rgba[4] = {0.f, 0.f, 0.f, 0.f}; - [self.viewController loadStartPage]; - XCTestExpectation *loadExpectation = [[XCTNSNotificationExpectation alloc] initWithName:CDVTestingDeviceReadyFired]; + [self.viewController loadStartPage]; [self waitForExpectations:@[loadExpectation] timeout:5]; - __block XCTestExpectation *redExpectation = [self expectationWithDescription:@"red"]; - [self.plugin evaluateJavaScript:@"window.statusbar.setBackgroundColor('red');" completionHandler:^(id _, NSError* error) { - XCTAssertNil(error); - [redExpectation fulfill]; - }]; - [self waitForExpectations:@[redExpectation] timeout:5]; + [self evaluateJavaScript:@"window.statusbar.setBackgroundColor('red');" +andWaitForCordovaCommandQueueWithWebViewEngine:self.plugin + timeout:5]; [self.viewController.statusBarBackgroundColor getRed:&rgba[0] green:&rgba[1] blue:&rgba[2] alpha:&rgba[3]]; XCTAssertEqual(rgba[0], 1.f); @@ -74,17 +75,13 @@ - (void) testStatusBarBackgroundColorNamedColor { - (void) testStatusBarBackgroundColor3HexColor { CGFloat rgba[4] = {0.f, 0.f, 0.f, 0.f}; - [self.viewController loadStartPage]; - XCTestExpectation *loadExpectation = [[XCTNSNotificationExpectation alloc] initWithName:CDVTestingDeviceReadyFired]; + [self.viewController loadStartPage]; [self waitForExpectations:@[loadExpectation] timeout:5]; - __block XCTestExpectation *redExpectation = [self expectationWithDescription:@"red"]; - [self.plugin evaluateJavaScript:@"window.statusbar.setBackgroundColor('#f00');" completionHandler:^(id _, NSError* error) { - XCTAssertNil(error); - [redExpectation fulfill]; - }]; - [self waitForExpectations:@[redExpectation] timeout:5]; + [self evaluateJavaScript:@"window.statusbar.setBackgroundColor('#f00');" +andWaitForCordovaCommandQueueWithWebViewEngine:self.plugin + timeout:5]; [self.viewController.statusBarBackgroundColor getRed:&rgba[0] green:&rgba[1] blue:&rgba[2] alpha:&rgba[3]]; XCTAssertEqual(rgba[0], 1.f); @@ -96,17 +93,13 @@ - (void) testStatusBarBackgroundColor3HexColor { - (void) testStatusBarBackgroundColor6HexColor { CGFloat rgba[4] = {0.f, 0.f, 0.f, 0.f}; - [self.viewController loadStartPage]; - XCTestExpectation *loadExpectation = [[XCTNSNotificationExpectation alloc] initWithName:CDVTestingDeviceReadyFired]; + [self.viewController loadStartPage]; [self waitForExpectations:@[loadExpectation] timeout:5]; - __block XCTestExpectation *redExpectation = [self expectationWithDescription:@"red"]; - [self.plugin evaluateJavaScript:@"window.statusbar.setBackgroundColor('#ff0000');" completionHandler:^(id _, NSError* error) { - XCTAssertNil(error); - [redExpectation fulfill]; - }]; - [self waitForExpectations:@[redExpectation] timeout:5]; + [self evaluateJavaScript:@"window.statusbar.setBackgroundColor('#ff0000');" +andWaitForCordovaCommandQueueWithWebViewEngine:self.plugin + timeout:5]; [self.viewController.statusBarBackgroundColor getRed:&rgba[0] green:&rgba[1] blue:&rgba[2] alpha:&rgba[3]]; XCTAssertEqual(rgba[0], 1.f); @@ -118,17 +111,13 @@ - (void) testStatusBarBackgroundColor6HexColor { - (void) testStatusBarBackgroundColor8HexColor { CGFloat rgba[4] = {0.f, 0.f, 0.f, 0.f}; - [self.viewController loadStartPage]; - XCTestExpectation *loadExpectation = [[XCTNSNotificationExpectation alloc] initWithName:CDVTestingDeviceReadyFired]; + [self.viewController loadStartPage]; [self waitForExpectations:@[loadExpectation] timeout:5]; - __block XCTestExpectation *redExpectation = [self expectationWithDescription:@"red"]; - [self.plugin evaluateJavaScript:@"window.statusbar.setBackgroundColor('#ff000080');" completionHandler:^(id _, NSError* error) { - XCTAssertNil(error); - [redExpectation fulfill]; - }]; - [self waitForExpectations:@[redExpectation] timeout:5]; + [self evaluateJavaScript:@"window.statusbar.setBackgroundColor('#ff000080');" +andWaitForCordovaCommandQueueWithWebViewEngine:self.plugin + timeout:5]; [self.viewController.statusBarBackgroundColor getRed:&rgba[0] green:&rgba[1] blue:&rgba[2] alpha:&rgba[3]]; XCTAssertEqual(rgba[0], 1.f); @@ -140,17 +129,13 @@ - (void) testStatusBarBackgroundColor8HexColor { - (void) testStatusBarBackgroundColorRGB { CGFloat rgba[4] = {0.f, 0.f, 0.f, 0.f}; - [self.viewController loadStartPage]; - XCTestExpectation *loadExpectation = [[XCTNSNotificationExpectation alloc] initWithName:CDVTestingDeviceReadyFired]; + [self.viewController loadStartPage]; [self waitForExpectations:@[loadExpectation] timeout:5]; - __block XCTestExpectation *redExpectation = [self expectationWithDescription:@"red"]; - [self.plugin evaluateJavaScript:@"window.statusbar.setBackgroundColor('rgb(255, 0, 0)');" completionHandler:^(id _, NSError* error) { - XCTAssertNil(error); - [redExpectation fulfill]; - }]; - [self waitForExpectations:@[redExpectation] timeout:5]; + [self evaluateJavaScript:@"window.statusbar.setBackgroundColor('rgb(255, 0, 0)');" +andWaitForCordovaCommandQueueWithWebViewEngine:self.plugin + timeout:5]; [self.viewController.statusBarBackgroundColor getRed:&rgba[0] green:&rgba[1] blue:&rgba[2] alpha:&rgba[3]]; XCTAssertEqual(rgba[0], 1.f); @@ -162,17 +147,13 @@ - (void) testStatusBarBackgroundColorRGB { - (void) testStatusBarBackgroundColorRGBA { CGFloat rgba[4] = {0.f, 0.f, 0.f, 0.f}; - [self.viewController loadStartPage]; - XCTestExpectation *loadExpectation = [[XCTNSNotificationExpectation alloc] initWithName:CDVTestingDeviceReadyFired]; + [self.viewController loadStartPage]; [self waitForExpectations:@[loadExpectation] timeout:5]; - __block XCTestExpectation *redExpectation = [self expectationWithDescription:@"red"]; - [self.plugin evaluateJavaScript:@"window.statusbar.setBackgroundColor('rgba(255, 0, 0, 0.25)');" completionHandler:^(id _, NSError* error) { - XCTAssertNil(error); - [redExpectation fulfill]; - }]; - [self waitForExpectations:@[redExpectation] timeout:5]; + [self evaluateJavaScript:@"window.statusbar.setBackgroundColor('rgba(255, 0, 0, 0.25)');" +andWaitForCordovaCommandQueueWithWebViewEngine:self.plugin + timeout:5]; [self.viewController.statusBarBackgroundColor getRed:&rgba[0] green:&rgba[1] blue:&rgba[2] alpha:&rgba[3]]; XCTAssertEqual(rgba[0], 1.f); diff --git a/tests/CordovaLibTests/CDVTestHelpers.h b/tests/CordovaLibTests/CDVTestHelpers.h index 42acdf2ec..9f9bb5a03 100644 --- a/tests/CordovaLibTests/CDVTestHelpers.h +++ b/tests/CordovaLibTests/CDVTestHelpers.h @@ -22,8 +22,45 @@ extern const NSNotificationName CDVTestingDeviceReadyFired; +@class CDVViewController; +@protocol CDVWebViewEngineProtocol; + @interface TestNavigationDelegate : NSObject @property (nonatomic, copy) void (^didFinishNavigation)(WKWebView *, WKNavigation *); - (void)waitForDidFinishNavigation:(XCTestExpectation *)expectation; @end + +@interface XCTestCase (CordovaTestHelpers) + +/** + * Registers the test-only Cordova command queue barrier plugin. + * + * Some tests execute JavaScript that calls Cordova APIs. Those APIs often call + * cordova.exec, which sends work from JavaScript to native code asynchronously. + * XCTest should not assert native state until that native work has actually + * been handled. Register this plugin before using + * evaluateJavaScript:andWaitForCordovaCommandQueueWithWebViewEngine:timeout:. + */ +- (void)registerCordovaCommandQueueTestPluginForViewController:(CDVViewController *)viewController; + +/** + * Evaluates JavaScript and waits until all cordova.exec calls queued by that + * JavaScript have reached native code. + * + * WKWebView's evaluateJavaScript completion handler only tells us that the + * JavaScript expression finished evaluating. It does not guarantee that native + * plugin commands triggered by cordova.exec have already run. This helper + * appends a test-only no-op exec call after the provided JavaScript and waits + * until that no-op command is received natively. Since Cordova processes exec + * messages in order, receiving the no-op command means earlier exec calls from + * the same JavaScript evaluation have already reached native code. + * + * This is a synchronization helper for tests that need to read native state + * after invoking JavaScript APIs backed by Cordova plugins. + */ +- (void)evaluateJavaScript:(NSString *)javaScript +andWaitForCordovaCommandQueueWithWebViewEngine:(id )webViewEngine + timeout:(NSTimeInterval)timeout; + +@end diff --git a/tests/CordovaLibTests/CDVTestHelpers.m b/tests/CordovaLibTests/CDVTestHelpers.m index d6b1c06bb..f7fe5d4c1 100644 --- a/tests/CordovaLibTests/CDVTestHelpers.m +++ b/tests/CordovaLibTests/CDVTestHelpers.m @@ -18,8 +18,40 @@ Licensed to the Apache Software Foundation (ASF) under one */ #import "CDVTestHelpers.h" +#import const NSNotificationName CDVTestingDeviceReadyFired = @"CDVTestingDeviceReadyFired"; +static NSNotificationName const CDVTestingCommandQueueBarrierReached = @"CDVTestingCommandQueueBarrierReached"; +static NSString * const CDVTestingCommandQueuePluginName = @"CordovaTestingCommandQueue"; + +/** + * Test-only Cordova plugin used as a synchronization barrier. + * + * The production WKWebView bridge delivers cordova.exec calls to native code + * asynchronously. A test that evaluates JavaScript and immediately checks native + * state can therefore race with the native plugin command that JavaScript + * enqueued. This plugin gives tests a known command that can be appended after + * the JavaScript under test. When this command reaches native code, all earlier + * exec commands from the same JavaScript evaluation have also reached native + * code. + * + * The plugin intentionally performs no production behavior. It only posts an + * XCTest-visible notification so the test can continue. + */ +@interface CDVTestingCommandQueue : CDVPlugin + +- (void)barrier:(CDVInvokedUrlCommand *)command; + +@end + +@implementation CDVTestingCommandQueue + +- (void)barrier:(CDVInvokedUrlCommand *)command +{ + [[NSNotificationCenter defaultCenter] postNotificationName:CDVTestingCommandQueueBarrierReached object:self]; +} + +@end @implementation TestNavigationDelegate - (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation @@ -39,3 +71,45 @@ - (void)waitForDidFinishNavigation:(XCTestExpectation *)expectation }; } @end + +@implementation XCTestCase (CordovaTestHelpers) + +- (void)registerCordovaCommandQueueTestPluginForViewController:(CDVViewController *)viewController +{ + // Register the barrier plugin with the Cordova plugin manager used by this + // test view controller. The JavaScript side refers to this plugin by + // CDVTestingCommandQueuePluginName when it appends the barrier exec call. + [viewController registerPlugin:[[CDVTestingCommandQueue alloc] init] withPluginName:CDVTestingCommandQueuePluginName]; +} + +- (void)evaluateJavaScript:(NSString *)javaScript +andWaitForCordovaCommandQueueWithWebViewEngine:(id )webViewEngine + timeout:(NSTimeInterval)timeout +{ + XCTestExpectation *commandQueueExpectation = [[XCTNSNotificationExpectation alloc] initWithName:CDVTestingCommandQueueBarrierReached]; + XCTestExpectation *javaScriptExpectation = [self expectationWithDescription:@"JavaScript evaluation"]; + // The barrier must be appended to the same JavaScript evaluation as the code + // under test. If it were evaluated in a separate evaluateJavaScript call, + // WebKit could complete or schedule the second evaluation before the first + // evaluation's cordova.exec message had been delivered to native code. By + // putting both exec calls in one JavaScript evaluation, JavaScript enqueues + // them in order and the native barrier becomes a reliable FIFO checkpoint. + NSString *barrier = [NSString stringWithFormat:@"\ncordova.exec(null, null, '%@', 'barrier', []);", CDVTestingCommandQueuePluginName]; + NSString *javaScriptWithQueueBarrier = [javaScript stringByAppendingString:barrier]; + + // Wait for both pieces: + // 1. evaluateJavaScript completed without a WebKit/JavaScript error. + // 2. The appended Cordova barrier command reached native code. + // + // Only after both are true is it safe for the test to assert native state + // that should have been changed by a synchronous plugin command. + [webViewEngine evaluateJavaScript:javaScriptWithQueueBarrier + completionHandler:^(id _, NSError* error) { + XCTAssertNil(error); + [javaScriptExpectation fulfill]; + }]; + + [self waitForExpectations:@[javaScriptExpectation, commandQueueExpectation] timeout:timeout]; +} + +@end