/****************************************************************************
* Copyright 2008-2011 ThoughtWorks, Inc.
*
* 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.
*
* Initial Contributors:
* Håkan Råberg
* Manish Chakravarty
* Pavan K S
***************************************************************************/
package com.thoughtworks.krypton.driver.web.browser;
import java.awt.Point;
import java.awt.Rectangle;
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;
import org.apache.xerces.parsers.DOMParser;
import org.cyberneko.html.HTMLConfiguration;
import org.eclipse.swt.browser.Browser;
import org.eclipse.swt.browser.StatusTextEvent;
import org.eclipse.swt.browser.StatusTextListener;
import org.eclipse.swt.widgets.Shell;
import org.mozilla.javascript.CompilerEnvirons;
import org.mozilla.javascript.Context;
import org.mozilla.javascript.ContextFactory;
import org.mozilla.javascript.ErrorReporter;
import org.mozilla.javascript.Parser;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Attr;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.EntityResolver;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import com.thoughtworks.krypton.driver.web.browser.jsmin.JSMin;
import com.thoughtworks.krypton.driver.web.browser.locator.ElementNotFoundException;
import com.thoughtworks.krypton.driver.web.browser.locator.LocatorStrategy;
import com.thoughtworks.krypton.driver.web.browser.wait.WaitStrategy;
import com.thoughtworks.krypton.driver.web.browser.wait.WaitTimedOutException;
public class SWTBrowserSession implements BrowserSession {
private static final String[] BOOLEAN_ATTRIBUTES = { "checked", "selected", "disabled", "readonly", "multiple" };
private static final int DEFAULT_EVENTLOOP_TIMEOUT = 10 * 1000;
private static Map<String, String> resourcesByName = new HashMap<String, String>();
private static Set<String> verifiedJavaScripts = new HashSet<String>();
private static Map<String, String> minifiedJavaScripts = new HashMap<String, String>();
private static Map<String, XPathExpression> compiledXPaths = new HashMap<String, XPathExpression>();
private Shell shell;
private DocumentBuilder documentBuilder;
private Browser browser;
private List<LocatorStrategy> locatorStrategies = new ArrayList<LocatorStrategy>();
private List<WaitStrategy> waitStrategies = new ArrayList<WaitStrategy>();
private int eventLoopTimeout = DEFAULT_EVENTLOOP_TIMEOUT;
private XPath xpath;
private DOMParser parser;
private Document document;
private String windowExpression = "window";
private Logger log = LoggerFactory.getLogger(getClass());
private BrowserFamily browserFamily;
public SWTBrowserSession(Browser browser, BrowserFamily browserFamily) {
this.browserFamily = browserFamily;
try {
this.shell = browser.getShell();
this.browser = browser;
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
documentBuilder = factory.newDocumentBuilder();
documentBuilder.setEntityResolver(new LocalEntityResolver());
xpath = XPathFactory.newInstance().newXPath();
HTMLConfiguration configuration = new HTMLConfiguration();
configuration.setProperty("http://cyberneko.org/html/properties/names/elems", "lower");
configuration.setProperty("http://cyberneko.org/html/properties/names/attrs", "lower");
parser = new DOMParser(configuration);
log.info("Created BrowserSession using browser {}", getBrowserFamily());
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public void openBrowser() {
log.debug("Opening Browser");
if (!shell.isVisible()) {
shell.setVisible(Boolean.parseBoolean(System.getProperty("twist.driver.web.visible", "true")));
shell.setFullScreen(Boolean.parseBoolean(System.getProperty("twist.driver.web.fullscreen", "false")));
shell.setMinimized(Boolean.parseBoolean(System.getProperty("twist.driver.web.minimized", "false")));
shell.setMaximized(Boolean.parseBoolean(System.getProperty("twist.driver.web.maximized", "false")));
}
}
public void closeBrowser() {
log.debug("Closing Browser");
if (!shell.isDisposed()) {
shell.close();
shell.dispose();
browser.dispose();
}
}
public synchronized void execute(String statements) {
verifyJavaScript(statements);
if (!browser.execute(statements)) {
throw new JavascriptException("Javascript Failed");
}
}
public synchronized String evaluate(String expression) {
return new StatusTransport().evaluate(expression);
}
public void inject(String script) {
inject(script, getClass());
}
public void inject(final String script, final Class<?> baseClass) {
log.trace("Injecting {} base class is {}", script, baseClass);
String code = minifiedJavaScripts.get(script);
if (code == null) {
code = readResource(script, baseClass);
verifyJavaScript(code);
code = minifyJavaScript(script, code);
minifiedJavaScripts.put(script, code);
}
browser.execute(code);
}
public BrowserFamily getBrowserFamily() {
return browserFamily;
}
public Document dom() {
// TODO: Figure out how to properly flush the cache (problem primarily in Safari), or kill it.
// if (document != null) {
// return document;
// }
String dom = "";
try {
inject("twist-dom.js");
return parseDOMUsingInnerHTML();
} catch (Exception e) {
log.error(dom, e);
throw new RuntimeException(e);
}
}
private Document parseDOMUsingInnerHTML() throws SAXException, IOException {
long now = System.currentTimeMillis();
String dom = evaluate("Twist.domFromInnerHTML(" + getDocumentExpression() + ".documentElement)");
parser.parse(new InputSource(new StringReader(dom)));
document = parser.getDocument();
postProcessAttributes(document);
log.warn("innerHTML took: {} ms. ({} chars)", (System.currentTimeMillis() - now), dom.length());
return document;
}
private void postProcessAttributes(Document document) {
NodeList allElements = document.getElementsByTagName("*");
for (int i = 0; i < allElements.getLength(); i++) {
Element element = (Element) allElements.item(i);
patchId(element);
patchAttributesForIE(element);
}
}
private void patchAttributesForIE(Element element) {
if (browserFamily == BrowserFamily.IE) {
String tagName = element.getTagName();
String type = element.getAttribute("type");
String domExpression = domExpressionWithoutLogging(element);
for (String attribute : BOOLEAN_ATTRIBUTES) {
element.setAttribute(attribute, element.hasAttribute(attribute) + "");
}
if ("textarea".equals(tagName)) {
element.setAttribute("value", element.getTextContent());
}
if ("input".equals(tagName)) {
if ("password".equals(type)) {
element.setAttribute("value", evaluate(domExpression + ".value"));
}
if ("".equals(type)) {
element.setAttribute("type", "text");
}
if (!element.hasAttribute("value")) {
element.setAttribute("value", "");
}
if (element.hasAttribute("name")) {
element.setAttribute("name", evaluate(domExpression + ".name"));
}
}
}
}
private void patchId(Element element) {
Attr id = element.getAttributeNode("id");
if (id != null) {
element.setIdAttributeNode(id, true);
}
}
public Rectangle boundingRectangle(Element element) {
inject("twist-bounding-rectangle.js");
String rectangle = evaluate("Twist.boundingRectangle(" + domExpression(element) + ")");
String[] values = rectangle.split(",");
Rectangle boundingRectangle = new Rectangle(Double.valueOf(values[0]).intValue(), Double.valueOf(values[1]).intValue(), Integer.parseInt(values[2]),
Integer.parseInt(values[3]));
log.trace("Bounding rectangle of {} is {}", element, boundingRectangle);
return boundingRectangle;
}
public Point center(Element element) {
Rectangle rectangle = boundingRectangle(element);
Point center = new Point(rectangle.x + rectangle.width / 2, rectangle.y + rectangle.height / 2);
log.trace("Center of {} is {}", element, center);
return center;
}
public Element locate(String locator) {
long now = System.currentTimeMillis();
pumpEvents();
for (LocatorStrategy strategy : locatorStrategies) {
if (strategy.canLocate(locator)) {
Element element = strategy.locate(this, locator);
if (element != null) {
log.debug("Located {} using '{}' (took {} ms.)", new Object[] {element, locator, (System.currentTimeMillis() - now)});
return element;
}
}
}
throw new ElementNotFoundException(locator);
}
public List<Node> locateAll(String xpathExpression) {
try {
XPathExpression compiled = compiledXPaths.get(xpathExpression);
if (compiled == null) {
compiled = xpath.compile(xpathExpression);
compiledXPaths.put(xpathExpression, compiled);
}
log.debug("Locating all elements using '{}'", xpathExpression);
List<Node> result = new ArrayList<Node>();
NodeList nodeList = (NodeList) compiled.evaluate(dom(), XPathConstants.NODESET);
for (int i = 0; i < nodeList.getLength(); i++) {
result.add(nodeList.item(i));
}
return result;
} catch (XPathExpressionException e) {
throw new RuntimeException(e);
}
}
public void fireEvent(Element element, String eventName) {
inject("twist-events.js", getClass());
log.debug("Firing JavaScript event '{}' on {}", eventName, element);
execute("Twist.fireEvent(" + domExpression(element) + ", '" + eventName + "')");
}
public void setCursorPosition(Element element, int position) {
inject("twist-cursor-position.js", getClass());
log.debug("Setting cursor positon to {} at {}", position, element);
execute("Twist.setCursorPosition(" + domExpression(element) + ", " + position + ")");
}
public int getCursorPosition(Element element) {
inject("twist-cursor-position.js", getClass());
int position = Integer.parseInt(evaluate("Twist.getCursorPosition(" + domExpression(element) + ")"));
log.debug("Cursor positon is {} at {}", position, element);
return position;
}
public void waitForIdle() {
log.debug("Waiting for Browser to become idle");
long timeout = System.currentTimeMillis() + eventLoopTimeout;
int count = 0;
while (!browser.isDisposed() && count < 2) {
pumpEvents();
if (System.currentTimeMillis() > timeout) {
throw new WaitTimedOutException("event loop to become idle, busy strategies: " + getBusyWaitStrategies(), eventLoopTimeout);
}
if (areWaitStrategiesIdle()) {
count++;
}
}
emptyDocumentCache();
pumpEvents();
}
public void pumpEvents() {
while (!browser.isDisposed() && browser.getDisplay().readAndDispatch())
;
}
private boolean areWaitStrategiesIdle() {
for (WaitStrategy strategy : waitStrategies) {
if (strategy.isBusy()) {
return false;
}
}
return true;
}
private List<WaitStrategy> getBusyWaitStrategies() {
List<WaitStrategy> result = new ArrayList<WaitStrategy>();
for (WaitStrategy strategy : waitStrategies) {
if (strategy.isBusy()) {
result.add(strategy);
}
}
return result;
}
public void addLocatorStrategy(LocatorStrategy locatorStrategy) {
locatorStrategies.add(locatorStrategy);
}
public void addWaitStrategy(WaitStrategy waitStrategy) {
waitStrategy.init(this);
waitStrategies.add(waitStrategy);
}
public String domExpression(Element element) {
Element original = element;
String domExpression = domExpressionWithoutLogging(element);
log.trace("DOM Expression of {} is '{}'", original, domExpression);
return domExpression;
}
private String domExpressionWithoutLogging(Element element) {
String expression = "";
while (!(element.getParentNode() instanceof Document)) {
expression = ".childNodes[" + element.getAttribute("twist.domindex") + "]" + expression;
element = (Element) element.getParentNode();
}
String domExpression = getDocumentExpression() + ".documentElement" + expression;
return domExpression;
}
public void setEventLoopTimeout(int timeout) {
this.eventLoopTimeout = timeout;
}
public String readResource(String resource, Class<?> baseClass) {
String key = baseClass.getPackage().getName() + "." + resource;
if (resourcesByName.containsKey(key)) {
return resourcesByName.get(key);
}
InputStream in = null;
try {
InputStream resourceAsStream = baseClass.getResourceAsStream(resource);
if (resourceAsStream == null) {
throw new IllegalArgumentException("Could not find resource " + resource + " baseclass is " + baseClass.getName());
}
in = new BufferedInputStream(resourceAsStream);
ByteArrayOutputStream out = new ByteArrayOutputStream();
int b = -1;
while ((b = in.read()) != -1) {
out.write(b);
}
String resourceAsString = new String(out.toByteArray(), "UTF-8");
resourcesByName.put(key, resourceAsString);
return resourceAsString;
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
try {
if (in != null) {
in.close();
}
} catch (IOException nothingToDo) {
}
}
}
private void verifyJavaScript(String script) {
if (verifiedJavaScripts.contains(script)) {
return;
}
Context ctx = ContextFactory.getGlobal().enterContext();
try {
CompilerEnvirons compilerEnv = new CompilerEnvirons();
compilerEnv.initFromContext(ctx);
ErrorReporter compilationErrorReporter = compilerEnv.getErrorReporter();
Parser parser = new Parser(compilerEnv, compilationErrorReporter);
parser.parse(script, "", 1);
verifiedJavaScripts.add(script);
} finally {
Context.exit();
}
}
private String minifyJavaScript(String script, String code) {
try {
ByteArrayOutputStream out = new ByteArrayOutputStream();
new JSMin(new ByteArrayInputStream(code.getBytes("UTF-8")), out).jsmin();
String minified = new String(out.toByteArray(), "UTF-8");
log.debug("Minified script {} from {} to {}", new Object[] {script, code.length(), minified.length()});
return minified;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private final class LocalEntityResolver implements EntityResolver {
public InputSource resolveEntity(String publicId, String systemId) throws SAXException, IOException {
if (publicId.equals("-//W3C//DTD XHTML 1.0 Strict//EN")) {
return new InputSource(new StringReader(readResource("xhtml1-strict.dtd", getClass())));
}
if (publicId.equals("-//W3C//ENTITIES Latin 1 for XHTML//EN")) {
return new InputSource(new StringReader(readResource("xhtml-lat1.ent", getClass())));
}
if (publicId.equals("-//W3C//ENTITIES Symbols for XHTML//EN")) {
return new InputSource(new StringReader(readResource("xhtml-symbol.ent", getClass())));
}
if (publicId.equals("-//W3C//ENTITIES Special for XHTML//EN")) {
return new InputSource(new StringReader(readResource("xhtml-special.ent", getClass())));
}
return null;
}
}
private class StatusTransport implements StatusTextListener {
private static final String RETURN_VALUE = "return: ";
private static final String EXCEPTION = "exception: ";
private String returnValue;
private JavascriptException exception;
public void changed(StatusTextEvent event) {
if (event.text.startsWith(RETURN_VALUE)) {
returnValue = event.text.substring(RETURN_VALUE.length());
}
if (event.text.startsWith(EXCEPTION)) {
exception = new JavascriptException(event.text.substring(EXCEPTION.length()));
}
}
public String evaluate(final String expression) {
log.debug("Executing JavaScript: {}", expression);
String script = "try { window.status = '" + RETURN_VALUE + "' + (" + expression + ");} catch (e) { window.status = '"
+ EXCEPTION + "' + e; }";
verifyJavaScript(script);
getBrowser().addStatusTextListener(StatusTransport.this);
pumpEvents();
getBrowser().execute(script);
pumpEvents();
getBrowser().removeStatusTextListener(StatusTransport.this);
if (exception != null) {
exception.fillInStackTrace();
log.debug("Caught JavaScript exception", exception);
throw exception;
}
log.debug("JavaScript returned: {}", returnValue);
return returnValue;
}
}
public String getText(Node node) {
String text = normalizeNewlines(escapeAposForIE(getText(node, false))).trim();
log.debug("Text of {} is: {}", node, text);
return text;
}
private String escapeAposForIE(String text) {
return text.replaceAll("'", "'");
}
private String getText(Node node, boolean preformatted) {
if (Node.TEXT_NODE == node.getNodeType()) {
String text = node.getTextContent();
if (!preformatted) {
text = normalizeSpaces(text);
}
return text;
}
if (Node.ELEMENT_NODE == node.getNodeType()) {
Element element = (Element) node;
String tagName = element.getTagName().toLowerCase().intern();
if ("pre" == tagName) {
preformatted = true;
}
String text = "";
for (int i = 0; i < element.getChildNodes().getLength(); i++) {
text += getText(element.getChildNodes().item(i), preformatted);
}
if (tagName == "p" || tagName == "br" || tagName == "hr" || tagName == "div") {
text += "\n";
}
return text;
}
return "";
}
private String normalizeNewlines(String text) {
return text.replace("/\r\n|\r/g", "\n");
}
private String normalizeSpaces(String text) {
char nonBreakingSpace = (char) 160;
return text.replaceAll("\\s+", " ").replace(nonBreakingSpace, ' ');
}
public boolean isVisible(Element element) {
inject("twist-is-visible.js");
return Boolean.parseBoolean(evaluate("Twist.isVisible(" + domExpression(element) + ")"));
}
public void setWindowExpression(String domExpression) {
log.debug("Changing target window to: '{}'", domExpression);
emptyDocumentCache();
inject("twist-normalize-frame.js");
windowExpression = evaluate("Twist.normalizeFrame(" + domExpression + ")");
log.debug("Target window normalized as: '{}'", windowExpression);
}
private void emptyDocumentCache() {
document = null;
}
public String getWindowExpression() {
return windowExpression;
}
public String getDocumentExpression() {
return windowExpression + ".document";
}
public Browser getBrowser() {
return browser;
}
}