| // |
| // WebViewController.m |
| // iWebDriver |
| // |
| // Copyright 2009 Google Inc. |
| // Copyright 2011 Software Freedom Conservancy. |
| // |
| // 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 <objc/runtime.h> |
| #import <QuartzCore/QuartzCore.h> |
| #import <QuartzCore/CATransaction.h> |
| |
| #import "errorcodes.h" |
| #import "FrameContext.h" |
| #import "GeoLocation.h" |
| #import "HTTPServerController.h" |
| #import "MainViewController.h" |
| #import "NSObject+SBJson.h" |
| #import "NSException+WebDriver.h" |
| #import "NSURLRequest+IgnoreSSL.h" |
| #import "UIResponder+SimulateTouch.h" |
| #import "WebDriverResponse.h" |
| #import "WebDriverPreferences.h" |
| #import "WebDriverRequestFetcher.h" |
| #import "WebDriverUtilities.h" |
| #import "WebViewController.h" |
| |
| static const NSString* kGeoLocationKey = @"location"; |
| static const NSString* kGeoLongitudeKey = @"longitude"; |
| static const NSString* kGeoLatitudeKey = @"latitude"; |
| static const NSString* kGeoAltitudeKey = @"altitude"; |
| |
| @implementation WebViewController |
| |
| - (id)init { |
| [super init]; |
| |
| lastJSResult_ = nil; |
| screenshot_ = nil; |
| |
| WebDriverPreferences *preferences = [WebDriverPreferences sharedInstance]; |
| |
| cachePolicy_ = [preferences cache_policy]; |
| NSURLCache *sharedCache = [NSURLCache sharedURLCache]; |
| [sharedCache setDiskCapacity:[preferences diskCacheCapacity]]; |
| [sharedCache setMemoryCapacity:[preferences memoryCacheCapacity]]; |
| |
| if ([[preferences mode] isEqualToString: @"Server"]) { |
| HTTPServerController* serverController = [HTTPServerController sharedInstance]; |
| [serverController setViewController:self]; |
| [self describeLastAction:[serverController status]]; |
| } else { |
| WebDriverRequestFetcher* fetcher = [WebDriverRequestFetcher sharedInstance]; |
| [fetcher setViewController:self]; |
| [self describeLastAction:[fetcher status]]; |
| } |
| |
| return self; |
| } |
| |
| - (void)didReceiveMemoryWarning { |
| NSLog(@"Memory warning recieved."); |
| // TODO(josephg): How can we send this warning to the user? Maybe set the |
| // displayed text; though that could be overwritten basically straight away. |
| [super didReceiveMemoryWarning]; |
| } |
| |
| - (void)dealloc { |
| [lastJSResult_ release]; |
| [screenshot_ release]; |
| [super dealloc]; |
| } |
| |
| - (UIWebView *)webView { |
| return [[MainViewController sharedInstance] webView]; |
| } |
| |
| - (BOOL)webView:(UIWebView *)webView |
| shouldStartLoadWithRequest:(NSURLRequest *)request |
| navigationType:(UIWebViewNavigationType)navigationType { |
| |
| NSURL* url = [request URL]; |
| if ([[url scheme] isEqualToString:@"webdriver"]) { |
| NSLog(@"Received webdriver data from the current page: %@", |
| [url absoluteString]); |
| |
| NSString* action = [url host]; |
| if (action == nil) { |
| NSLog(@"No action specified; ignoring webdriver:// URL: %@", |
| [url absoluteString]); |
| return NO; |
| } |
| |
| NSString* jsonData = @"{}"; |
| if ([url query] != nil) { |
| jsonData = [[url query] |
| stringByReplacingPercentEscapesUsingEncoding:NSUTF8StringEncoding]; |
| } |
| |
| // TODO: catch malformed query data and broadcast an appropriate error. |
| NSDictionary* data = (NSDictionary*) [jsonData JSONValue]; |
| [[NSNotificationCenter defaultCenter] |
| postNotificationName:[NSString stringWithFormat:@"webdriver:%@", action] |
| object:self |
| userInfo:data]; |
| return NO; |
| } |
| |
| NSLog(@"shouldStartLoadWithRequest"); |
| return YES; |
| } |
| |
| - (void)webViewDidStartLoad:(UIWebView *)webView { |
| NSLog(@"webViewDidStartLoad"); |
| [[NSNotificationCenter defaultCenter] |
| postNotificationName:@"webdriver:pageLoad" |
| object:self]; |
| @synchronized(self) { |
| numPendingPageLoads_ += 1; |
| } |
| } |
| |
| - (void)webViewDidFinishLoad:(UIWebView *)webView { |
| NSLog(@"finished loading"); |
| @synchronized(self) { |
| numPendingPageLoads_ -= 1; |
| } |
| } |
| |
| - (void)webView:(UIWebView *)webView didFailLoadWithError:(NSError *)error { |
| // This is a very troubled method. It can be called multiple times (for each |
| // frame of webpage). It is sometimes called even when the page seems to have |
| // loaded correctly. |
| |
| // Page loading errors are ignored because that's what WebDriver expects. |
| NSLog(@"*** WebView failed to load URL with error %@", error); |
| if ([error code] == 101) { |
| NSString *failingURLString = [[error userInfo] objectForKey: |
| @"NSErrorFailingURLStringKey"]; |
| // This is an issue only with simulator due to lack of support for tel: url. |
| if ([[failingURLString substringToIndex:4] isEqualToString:@"tel:"]) { |
| @throw [NSException webDriverExceptionWithMessage: |
| [NSString stringWithFormat: |
| @"tel: url isn't supported in simulator"] |
| andStatusCode:EUNHANDLEDERROR]; |
| } |
| } |
| |
| @synchronized(self) { |
| numPendingPageLoads_ -= 1; |
| } |
| } |
| |
| #pragma mark Web view controls |
| |
| - (void)performSelectorOnWebView:(SEL)selector withObject:(id)obj { |
| [[self webView] performSelector:selector withObject:obj]; |
| } |
| |
| - (void)waitForLoad { |
| // TODO(josephg): Test sleep intervals on the device. |
| // This delay should be long enough that the webview has isLoading |
| // set correctly (but as short as possible - these delays slow down testing.) |
| |
| // - The problem with [view isLoading] is that it gets set in a separate |
| // worker thread. So, right after asking the webpage to load a URL we need to |
| // wait an unspecified amount of time before isLoading will correctly tell us |
| // whether the page is loading content. |
| |
| [NSThread sleepForTimeInterval:0.2f]; |
| |
| while ([[self webView] isLoading]) { |
| // Yield. |
| [NSThread sleepForTimeInterval:0.01f]; |
| } |
| |
| // The main view may be loaded, but there may be frames that are still |
| // loading. |
| while (true) { |
| @synchronized(self) { |
| if (numPendingPageLoads_ == 0) { |
| break; |
| } |
| } |
| } |
| } |
| |
| // All method calls on the view need to be done from the main thread to avoid |
| // synchronization errors. This method calls a given selector in this class |
| // optionally with an argument. |
| // |
| // If called with waitUntilLoad:YES, we wait for a web page to be loaded in the |
| // view before returning. |
| - (void)performSelectorOnView:(SEL)selector |
| withObject:(id)value |
| waitUntilLoad:(BOOL)wait { |
| |
| /* The problem with this method is that the UIWebView never gives us any clear |
| * indication of whether or not it's loading and if so, when its done. Asking |
| * it to load causes it to begin loading sometime later (isLoading returns NO |
| * for awhile.) Even the |webViewDidFinishLoad:| method isn't a sure sign of |
| * anything - it will be called multiple times, once for each frame of the |
| * loaded page. |
| * |
| * The result: The only effective method I can think of is nasty polling. |
| */ |
| |
| while ([[self webView] isLoading]) |
| [NSThread sleepForTimeInterval:0.01f]; |
| |
| [[self webView] performSelectorOnMainThread:selector |
| withObject:value |
| waitUntilDone:YES]; |
| |
| NSLog(@"loading %d", [[self webView] isLoading]); |
| |
| if (wait) |
| [self waitForLoad]; |
| } |
| |
| // Get the specified URL and block until it's finished loading. |
| - (void)setURL:(NSDictionary *)urlMap { |
| NSString *urlString = (NSString*) [urlMap objectForKey:@"url"]; |
| NSURLRequest *url = [NSURLRequest requestWithURL:[NSURL URLWithString:urlString] |
| cachePolicy:cachePolicy_ |
| timeoutInterval:60]; |
| |
| [self performSelectorOnView:@selector(loadRequest:) |
| withObject:url |
| waitUntilLoad:YES]; |
| // setting the URL happens on the main container, all |
| // switch_to's are reset. |
| [[FrameContext sharedInstance] removeAllObjects]; |
| } |
| |
| - (void)back:(NSDictionary*)ignored { |
| [self describeLastAction:@"back"]; |
| [self performSelectorOnView:@selector(goBack) |
| withObject:nil |
| waitUntilLoad:YES]; |
| } |
| |
| - (void)forward:(NSDictionary*)ignored { |
| [self describeLastAction:@"forward"]; |
| [self performSelectorOnView:@selector(goForward) |
| withObject:nil |
| waitUntilLoad:YES]; |
| } |
| |
| - (void)refresh:(NSDictionary*)ignored { |
| [self describeLastAction:@"refresh"]; |
| [self performSelectorOnView:@selector(reload) |
| withObject:nil |
| waitUntilLoad:YES]; |
| } |
| |
| // Currently only one window handle is supported |
| // These are just here to support WebDriverBackedSelenium |
| -(NSString*)windowHandle { |
| return @"1"; |
| } |
| |
| -(NSArray*)windowHandles { |
| return [[NSArray alloc] initWithObjects:@"1", nil]; |
| } |
| |
| // Return the device's current orientation as a string |
| -(NSString*)currentOrientation { |
| |
| if (([[UIDevice currentDevice] orientation] == UIDeviceOrientationLandscapeLeft) || |
| ([[UIDevice currentDevice] orientation] == UIDeviceOrientationLandscapeRight)) { |
| |
| return @"LANDSCAPE"; |
| } |
| if (([[UIDevice currentDevice] orientation] == UIDeviceOrientationPortrait) || |
| ([[UIDevice currentDevice] orientation] == UIDeviceOrientationPortraitUpsideDown)) { |
| |
| return @"PORTRAIT"; |
| } |
| // I'm not sure why but the initial state of the app seems to set the current orientation |
| // to unknown (0). Will hopefully look into this in the near future ;) TODO (lukeis) |
| return @"UNKNOWN"; |
| } |
| |
| -(void)window:(NSDictionary*)ignored { |
| // window switching isn't supported |
| return; |
| } |
| |
| - (void)frame:(NSDictionary*)frameTarget { |
| NSObject* ID = [frameTarget objectForKey:@"id"]; |
| NSString* frameIndex = nil; |
| if ([ID isKindOfClass:[NSNull class]]) { |
| // Switch to default content |
| [self describeLastAction:@"switch frame to top"]; |
| [[FrameContext sharedInstance] removeAllObjects]; |
| return; |
| } else if ([ID isKindOfClass:[NSDictionary class]]) { |
| // A WebElement was passed in |
| [self describeLastAction:@"switching frame to provided web element"]; |
| |
| // need to make a separate call to 'execute_script' |
| // in order to get the benefits of mapping the webelement to an actual |
| // dom element. Seemingly the easiest way is to spoof another external |
| // call and process the result |
| WebDriverResponse* response = (WebDriverResponse*) |
| [[HTTPServerController sharedInstance] |
| httpResponseForQuery:[[NSString alloc] initWithFormat:@"/wd/hub/session/%@/execute", [frameTarget objectForKey:@"sessionId"]] |
| method:@"POST" |
| // example of what the data needs to look like |
| // {"sessionId": "%@", "args": [{"ELEMENT": "%@"}], "script": "return arguments[0]"} |
| withData:[[[NSDictionary dictionaryWithObjectsAndKeys: |
| [[NSArray alloc] initWithObjects:ID, nil], @"args", |
| @"return (function(vs,v){for(var i=0;i<vs.length;i++){if(vs[i]==v)return String(i);}return '';})(window.frames,arguments[0].contentWindow)", @"script", nil] JSONRepresentation] |
| dataUsingEncoding:NSASCIIStringEncoding] |
| ]; |
| |
| frameIndex = [response value]; |
| |
| } else { |
| // We should have a string here which will either be the frame name, |
| // frame index or the id of the element. Let's try frame name / index first. |
| [self describeLastAction:@"switching frame by index/name"]; |
| frameIndex = [self jsEval:[[NSString alloc] |
| initWithFormat:@"(function(vs,v){for(var i=0;i<vs.length;i++){if(vs[i]==v)return String(i);}return '';})(window.frames,window.frames['%@'])", |
| ID]]; |
| if ([frameIndex isEqual:nil] || [frameIndex isEqualToString:@""]) { |
| // couldn't find the frame by name or index |
| // try to see if there's a frame with an id |
| [self describeLastAction:@"switching frame by id"]; |
| frameIndex = [self jsEval:[[NSString alloc] |
| initWithFormat:@"(function(vs,v){for(var i=0;i<vs.length;i++){if(vs[i]==v)return String(i);}return '';})(window.frames,document.getElementById('%@').contentWindow)", |
| ID]]; |
| } |
| } |
| if (![frameIndex isEqual:nil] && ![frameIndex isEqualToString:@""]) { |
| [[FrameContext sharedInstance] addObject:frameIndex]; |
| } else { |
| [self describeLastAction:@"switch frame could not find frame"]; |
| // NoSuchFrame Exception is 8 |
| // according to http://code.google.com/p/selenium/wiki/JsonWireProtocol#Response_Status_Codes |
| @throw [NSException webDriverExceptionWithMessage: |
| [NSString stringWithFormat:@"Could not find frame '%@' in the current window", ID] |
| andStatusCode:8]; |
| } |
| } |
| |
| - (id)visible { |
| // The WebView is always visible. |
| return [NSNumber numberWithBool:YES]; |
| } |
| |
| // Ignored. |
| - (void)setVisible:(NSNumber *)target { |
| } |
| |
| // Execute js in the main thread and set lastJSResult_ appropriately. |
| // This function must be executed on the main thread. Its designed to be called |
| // using performSelectorOnMainThread:... which doesn't return a value - so |
| // the return value is passed back through a class parameter. |
| - (void)jsEvalInternal:(NSString *)script { |
| // We wrap the eval command in a CATransaction so that we can explicitly |
| // force any UI updates that might occur as a side effect of executing the |
| // javascript to finish rendering before we return control back to the HTTP |
| // server thread. We actually found some cases where the rendering was |
| // finishing before control returned and so the core animation framework would |
| // defer committing its implicit transaction until the next iteration of the |
| // HTTP server thread's run loop. However, because you're only allowed to |
| // update the UI on the main application thread, committing it on the HTTP |
| // server thread would cause the whole application to crash. |
| // This feels like it shouldn't be necessary but it was the only way we could |
| // find to avoid the problem. |
| [CATransaction begin]; |
| [lastJSResult_ release]; |
| lastJSResult_ = [[[self webView] |
| stringByEvaluatingJavaScriptFromString:script] retain]; |
| [CATransaction commit]; |
| |
| NSLog(@"jsEval: %@ -> %@", script, lastJSResult_); |
| } |
| |
| // Evaluate the given JS format string & arguments. Argument list is the same |
| // as [NSString stringWithFormat:...]. |
| - (NSString *)jsEval:(NSString *)format, ... { |
| if (format == nil) { |
| [NSException raise:@"invalidArguments" format:@"Invalid arguments for jsEval"]; |
| } |
| |
| va_list argList; |
| va_start(argList, format); |
| NSString *script = [[[NSString alloc] initWithFormat:format |
| arguments:argList] |
| autorelease]; |
| va_end(argList); |
| |
| if ([[FrameContext sharedInstance] count] > 0) { |
| // check first is the frames still exist, if any of them are gone |
| // automatically reset to default content |
| [self performSelectorOnMainThread:@selector(jsEvalInternal:) |
| withObject:[NSString stringWithFormat:@"(function(){var w=window;var frameIndexes=%@;for(var i=0;i<frameIndexes.length;i++){if(!(w=w.frames[frameIndexes[i]]))return true;};return false;})()", |
| [[FrameContext sharedInstance] JSONRepresentation]] |
| waitUntilDone:YES]; |
| if ([[[lastJSResult_ copy] autorelease] isEqualToString:@"true"]) { |
| // this means one of the frames no longer exists, switching back to default content. |
| [[FrameContext sharedInstance] removeAllObjects]; |
| } else { |
| script = [[NSString alloc] |
| initWithFormat:@"(function(){var win=(function(){var w=window;var frameIndexes=%@;for(var i=0;i<frameIndexes.length;i++){if(!(w=w.frames[frameIndexes[i]]))return window;};return w;})(); return win.eval('%@');})()", |
| [[FrameContext sharedInstance] JSONRepresentation], |
| // [script JSONRepresentation] |
| // It would have been so nice to just use the JSON serializer |
| // but as luck would have it, it fails to convert most of our |
| // atomized javascript code. |
| // So, resorting to a string replacement to escape certain characters |
| // to coerce into a javascript string for eval. |
| [[[[script stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"] |
| stringByReplacingOccurrencesOfString:@"'" withString:@"\\'"] |
| stringByReplacingOccurrencesOfString:@"\\\\x" withString:@"\\x"] |
| stringByReplacingOccurrencesOfString:@"\n" withString:@""] |
| ]; |
| } |
| } |
| [self performSelectorOnMainThread:@selector(jsEvalInternal:) |
| withObject:script |
| waitUntilDone:YES]; |
| |
| return [[lastJSResult_ copy] autorelease]; |
| } |
| |
| - (NSString *)currentTitle { |
| // always return the 'visible' title which is on the top window |
| // not in the frameset |
| return [self jsEval:@"window.top.document.title"]; |
| } |
| |
| - (NSString *)source { |
| return [self jsEval:@"new XMLSerializer().serializeToString(document);"]; |
| } |
| |
| // Takes a screenshot. |
| - (UIImage *)screenshot { |
| [self performSelectorOnMainThread:@selector(getFullPageScreenShot) withObject:nil waitUntilDone:YES]; |
| return screenshot_; |
| } |
| |
| - (NSString *)URL { |
| return [self jsEval:@"window.location.href"]; |
| } |
| |
| - (void)describeLastAction:(NSString *)status { |
| [[MainViewController sharedInstance] describeLastAction:status]; |
| } |
| |
| - (CGRect)viewableArea { |
| CGRect area; |
| area.origin.x = [[self jsEval:@"window.pageXOffset"] intValue]; |
| area.origin.y = [[self jsEval:@"window.pageYOffset"] intValue]; |
| area.size.width = [[self jsEval:@"window.innerWidth"] intValue]; |
| area.size.height = [[self jsEval:@"window.innerHeight"] intValue]; |
| return area; |
| } |
| |
| - (BOOL)pointIsViewable:(CGPoint)point { |
| return CGRectContainsPoint([self viewableArea], point); |
| } |
| |
| // Scroll to make the given point centered on the screen (if possible). |
| - (void)scrollIntoView:(CGPoint)point { |
| // Webkit will clip the given point if it lies outside the window. |
| // It may be necessary at some stage to do this using touches. |
| [self jsEval:@"window.scroll(%f - window.innerWidth / 2, %f - window.innerHeight / 2);", point.x, point.y]; |
| } |
| |
| // Translate pixels in webpage-space to pixels in view space. |
| - (CGPoint)translatePageCoordinateToView:(CGPoint)point { |
| CGRect viewBounds = [[self webView] bounds]; |
| CGRect pageBounds = [self viewableArea]; |
| |
| // ... And then its just a linear transformation. |
| float scale = viewBounds.size.width / pageBounds.size.width; |
| CGPoint transformedPoint; |
| transformedPoint.x = (point.x - pageBounds.origin.x) * scale; |
| transformedPoint.y = (point.y - pageBounds.origin.y) * scale; |
| |
| NSLog(@"%@ -> %@", |
| NSStringFromCGPoint(point), |
| NSStringFromCGPoint(transformedPoint)); |
| |
| return transformedPoint; |
| } |
| |
| - (void)clickOnPageElementAt:(CGPoint)point { |
| if (![self pointIsViewable:point]) { |
| [self scrollIntoView:point]; |
| } |
| |
| CGPoint pointInViewSpace = [self translatePageCoordinateToView:point]; |
| |
| NSLog(@"simulating a click at %@", NSStringFromCGPoint(pointInViewSpace)); |
| [[self webView] simulateTapAt:pointInViewSpace]; |
| } |
| |
| // Gets the location |
| - (NSDictionary *)location { |
| GeoLocation *locStorage = [GeoLocation sharedManager]; |
| CLLocationCoordinate2D coordinate = [locStorage getCoordinate]; |
| CLLocationDistance altitude = [locStorage getAltitude]; |
| |
| return [NSDictionary dictionaryWithObjectsAndKeys: |
| [NSDecimalNumber numberWithDouble:coordinate.longitude], kGeoLongitudeKey, |
| [NSDecimalNumber numberWithDouble:coordinate.latitude], kGeoLatitudeKey, |
| [NSDecimalNumber numberWithFloat:altitude], kGeoAltitudeKey, nil]; |
| } |
| |
| // Sets the location |
| - (void)setLocation:(NSDictionary *)dict { |
| NSDictionary *values = [dict objectForKey:kGeoLocationKey]; |
| NSDecimalNumber *altitude = [values objectForKey:kGeoAltitudeKey]; |
| NSDecimalNumber *longitude = [values objectForKey:kGeoLongitudeKey]; |
| NSDecimalNumber *latitude = [values objectForKey:kGeoLatitudeKey]; |
| |
| GeoLocation *locStorage = [GeoLocation sharedManager]; |
| [locStorage setCoordinate:[longitude doubleValue] |
| latitude:[latitude doubleValue]]; |
| [locStorage setAltitude:[altitude doubleValue]]; |
| } |
| |
| // get the full page screenshot. |
| - (void)getFullPageScreenShot { |
| // stop the webview |
| [self.webView setDelegate:nil]; |
| [self.webView stopLoading]; |
| |
| // keep the original window size. |
| CGRect oriFrame = self.webView.frame; |
| CGRect oriBounds = self.webView.bounds; |
| |
| // get the page render actual size |
| CGRect tmpFrame = self.webView.frame; |
| tmpFrame.size.height = 1; |
| self.webView.frame = tmpFrame; |
| CGSize fittingSize = [self.webView sizeThatFits:CGSizeZero]; |
| tmpFrame.size = fittingSize; |
| self.webView.frame = tmpFrame; |
| |
| // render the page and take the screenshot |
| UIGraphicsBeginImageContext(fittingSize); |
| CGContextRef resizedContext = UIGraphicsGetCurrentContext(); |
| [[self webView].layer renderInContext:resizedContext]; |
| UIImage *viewImage = UIGraphicsGetImageFromCurrentImageContext(); |
| UIGraphicsEndImageContext(); |
| |
| //reset webview back to original size. |
| self.webView.bounds = oriBounds; |
| self.webView.frame = oriFrame; |
| |
| // retain the screenshot for webdriver |
| [screenshot_ release]; |
| screenshot_ = [viewImage retain]; |
| } |
| |
| // Finds out if browser connection is alive |
| - (NSNumber *)isBrowserOnline { |
| BOOL onlineState = [[self jsEval:@"navigator.onLine"] isEqualToString:@"true"]; |
| return [NSNumber numberWithBool:onlineState]; |
| } |
| |
| @end |