browser.coffee | |
---|---|
require "./jsdom_patches"
require "./forms"
require "./xpath"
{ deprecated } = require("./helpers")
{ Cache } = require("./cache")
{ Console } = require("./console")
{ Cookies } = require("./cookies")
{ EventEmitter } = require("events")
{ EventLoop } = require("./eventloop")
{ History } = require("./history")
{ HTML5 } = require("html5")
Interact = require("./interact")
JSDom = require("jsdom")
{ Resources } = require("./resources")
{ Storages } = require("./storage")
URL = require("url")
WebSocket = require("./websocket")
XHR = require("./xhr")
HTML = JSDom.dom.level3.html
MOUSE_EVENT_NAMES = ["mousedown", "mousemove", "mouseup"]
BROWSER_OPTIONS = ["credentials", "debug", "htmlParser", "loadCSS", "runScripts", "silent", "site", "userAgent", "waitFor"]
PACKAGE = JSON.parse(require("fs").readFileSync(__dirname + "/../../package.json"))
VERSION = PACKAGE.version | |
Use the browser to open up new windows and load documents. The browser maintains state for cookies and localStorage. | class Browser extends EventEmitter
constructor: (options = {}) ->
@_cache = new Cache()
@_cookies = new Cookies()
@_eventloop = new EventLoop(this)
@_storages = new Storages()
@_interact = Interact.use(this)
@_xhr = XHR.use(@_cache)
@_ws = WebSocket.use(this)
@resources = new Resources(this) |
Make sure we don't blow up Node when we get a JS error, but dump error to console. Also, catch any errors reported while processing resources/JavaScript. | @on "error", (error)=>
@errors.push error
@log error.message, error.stack |
Options | |
credentialsObject containing authorization credentials. Supported schemes include
Example creadentials = { scheme: "basic", username: "bloody", password: "hungry" } browser.visit("/basic/auth", { creadentials: creadentials }, function(error, browser) { }) creadentials = { scheme: "bearer", token: "b1004a8" } browser.visit("/oauth/2", { creadentials: creadentials }, function(error, browser) { }) | @credentials = false |
debugTrue to have Zombie report what it's doing. | @debug = false |
htmlParserWhich parser to use (HTML5 by default). For example: zombie.htmlParser = require("html5").HTML5 | @htmlParser = null |
loadCSSTrue to load external stylesheets. | @loadCSS = true |
runScriptsRun scripts included in or loaded from the page. Defaults to true. | @runScripts = true |
silentIf true, supress | @silent = false |
userAgentUser agent string sent to server. | @userAgent = "Mozilla/5.0 Chrome/10.0.613.0 Safari/534.15 Zombie.js/#{VERSION}" |
siteYou can use visit with a path, and it will make a request relative to this host/URL. | @site = null |
waitForTells | @waitFor = 5000 |
Sets the browser options. | for name in BROWSER_OPTIONS
value = options[name]
if typeof value == "undefined"
value = exports[name]
unless typeof value == "undefined"
@[name] = value
else
@[name] = value |
browser.errors => ArrayReturns all errors reported while loading this window. | @errors = [] |
Always start with an open window. | @open() |
withOptions(options, fn)Changes the browser options, and calls the function with a callback (reset). When you're done processing, call the reset function to bring options back to their previous values. See | withOptions: (options, fn)->
if options
restore = {}
[restore[k], @[k]] = [@[k], v] for k,v of options
fn =>
@[k] = v for k,v of restore if restore |
browser.fork() => BrowserReturn a new browser with a snapshot of this browser's state. Any changes to the forked browser's state do not affect this browser. | fork: ->
forked = new Browser()
forked.loadCookies @saveCookies()
forked.loadStorage @saveStorage()
forked.loadHistory @saveHistory()
return forked |
Windows | |
browser.open() => WindowOpen new browser window. Options are undocumented, use at your own peril. | open: (options)-> |
Add context for evaluating scripts. | newWindow = JSDom.createWindow(HTML)
Object.defineProperty newWindow, "browser", value: this |
Evaulate in context of window. This can be called with a script (String) or a function. | newWindow._evaluate = (code, filename)->
if typeof code == "string" || code instanceof String
newWindow.run code, filename
else
code.call newWindow |
If top window, switch browser to new window, clear any resources and errors. | if parent = options?.parent
newWindow.parent = parent
newWindow.top = parent.top
else
@_eventloop.reset()
@window = newWindow
@errors = []
@resources.clear()
@_eventloop.apply newWindow
Object.defineProperty newWindow, "history", value: options?.history || new History(newWindow)
newWindow.__defineGetter__ "title", ->
return newWindow?.document?.title
newWindow.__defineSetter__ "title", (title)->
return newWindow?.document?.title = title
|
Present in browsers, not in spec Used by Google Analytics see https://developer.mozilla.org/en/DOM/window.navigator.javaEnabled | newWindow.navigator.javaEnabled = ->
return false
newWindow.navigator.userAgent = @userAgent
@_cookies.extend newWindow
@_storages.extend newWindow
@_interact.extend newWindow
@_xhr.extend newWindow
@_ws.extend newWindow
newWindow.screen = new Screen()
newWindow.JSON = JSON
newWindow.Image = (width, height)->
img = new HTML.HTMLImageElement(newWindow.document)
img.width = width
img.height = height
return img
newWindow.console = new Console(@silent) |
Default onerror handler. | newWindow.onerror = (event)=>
error = event.error || new Error("Error loading script")
@emit "error", error
return newWindow |
browser.error => ErrorReturns the last error reported while loading this window. | @prototype.__defineGetter__ "error", ->
return @errors[@errors.length - 1]
|
Events | |
browser.wait(callback?)browser.wait(duration, callback)Waits for the browser to complete loading resources and processing JavaScript events. When done, calls the callback with null and browser. With Without duration, Zombie makes best judgement by waiting up to 5 seconds for the page to load resources (scripts, XHR requests, iframes), process DOM events, and fire timeouts events. You can also call | wait: (duration, callback)->
if !callback && typeof duration == "function"
[callback, duration] = [duration, null]
@_eventloop.wait @window, duration, =>
if callback
callback null, this
return |
browser.fire(name, target, callback?)Fire a DOM event. You can use this to simulate a DOM event, e.g. clicking a link. These events will bubble up and
can be cancelled. With a callback, this method will call name - Even name (e.g | fire: (name, target, options, callback)->
[callback, options] = [options, null] if typeof options is "function"
options ?= {}
type = options.type || (if name in MOUSE_EVENT_NAMES then "MouseEvents" else "HTMLEvents")
event = @window.document.createEvent(type)
event.initEvent(name, !!options.bubbles, !!options.cancelable)
if options.attributes?
for key, value of options.attributes
event[key] = value
@dispatchEvent target, event
if callback
@wait 0, callback
else
return this |
Dispatch asynchronously. | dispatchEvent: (target, event)->
@_eventloop.dispatch target, event |
Accessors | |
browser.queryAll(selector, context?) => ArrayEvaluates the CSS selector against the document (or context node) and return array of nodes.
(Unlike | queryAll: (selector, context)->
context ||= @document
if selector
return context.querySelectorAll(selector).toArray()
else
return [context] |
browser.query(selector, context?) => ElementEvaluates the CSS selector against the document (or context node) and return an element. | query: (selector, context)->
context ||= @document
if selector
context.querySelector(selector)
else
return context |
browser.querySelector(selector) => ElementSelect a single element (first match) and return it. selector - CSS selector Returns an Element or null | querySelector: (selector)->
return @window.document?.querySelector(selector) |
browser.querySelectorAll(selector) => NodeListSelect multiple elements and return a static node list. selector - CSS selector Returns a NodeList or null | querySelectorAll: (selector)->
return @window.document?.querySelectorAll(selector) |
browser.text(selector, context?) => StringReturns the text contents of the selected elements. selector - CSS selector (if missing, entire document) context - Context element (if missing, uses document) Returns a string | text: (selector, context)->
if @document.documentElement
return @queryAll(selector, context).map((e)-> e.textContent).join("")
return @source |
browser.html(selector?, context?) => StringReturns the HTML contents of the selected elements. selector - CSS selector (if missing, entire document) context - Context element (if missing, uses document) Returns a string | html: (selector, context)->
if @document.documentElement
return @queryAll(selector, context).map((e)-> e.outerHTML.trim()).join("")
return @source |
Deprecated please use | css: (selector, context)->
deprecated "Browser.css is deprecated, please use browser.query and browser.queryAll instead."
return @queryAll(selector, context) |
browser.xpath(expression, context?) => XPathResultEvaluates the XPath expression against the document (or context node) and return the XPath result. Shortcut for
| xpath: (expression, context)->
return @document.evaluate(expression, context || @document) |
browser.document => DocumentReturns the main window's document. Only valid after opening a document (see | @prototype.__defineGetter__ "document", ->
return @window?.document |
browser.body => ElementReturns the body Element of the current document. | @prototype.__defineGetter__ "body", ->
return @window.document?.querySelector("body") |
browser.statusCode => NumberReturns the status code of the request for loading the window. | @prototype.__defineGetter__ "statusCode", ->
return @response[0] |
browser.success => BooleanTrue if the status code is 2xx. | @prototype.__defineGetter__ "success", ->
return @statusCode >= 200 && @statusCode < 300 |
browser.redirected => BooleanReturns true if the request for loading the window followed a redirect. | @prototype.__defineGetter__ "redirected", ->
return @resources.first?.response?.redirected |
source => StringReturns the unmodified source of the document loaded by the browser | @prototype.__defineGetter__ "source", ->
return @response[2] |
Navigation | |
browser.visit(url, callback?)browser.visit(url, options, callback)Loads document from the specified URL, processes events and calls the callback. If the second argument are options, uses these options for the duration of the request and resets the options afterwards. The callback is called with null, the browser, status code and array of resource/JavaScript errors. | visit: (url, options, callback)->
if typeof options is "function"
[callback, options] = [options, null]
@withOptions options, (reset_options)=>
if site = @site
site = "http://#{site}" unless /^https?:/i.test(site)
url = URL.resolve(site, URL.parse(URL.format(url)))
@window.history._assign url
@wait null, (error, browser)=>
reset_options()
if callback
callback error, browser, browser.statusCode, browser.errors
return |
browser.location => LocationReturn the location of the current document (same as | @prototype.__defineGetter__ "location", ->
return @window.location |
browser.location = urlChanges document location, loads new document if necessary (same as setting | @prototype.__defineSetter__ "location", (url)->
@window.location = url |
browser.link(selector) : ElementFinds and returns a link by its text content or selector. | link: (selector)->
if link = @querySelector(selector)
return link if link.tagName == "A"
for link in @querySelectorAll("body a")
if link.textContent.trim() == selector
return link
return |
browser.clickLink(selector, callback)Clicks on a link. Clicking on a link can trigger other events, load new page, etc: use a callback to be notified of completion. Finds link by text content or selector. selector - CSS selector or link text callback - Called with two arguments: error and browser | clickLink: (selector, callback)->
if link = @link(selector)
@fire "click", link, =>
callback null, this, @statusCode
else
throw new Error("No link matching '#{selector}'") |
browser.saveHistory() => StringSave history to a text string. You can use this to load the data later on using | saveHistory: ->
@window.history.save() |
browser.loadHistory(String)Load history from a text string (e.g. previously created using | loadHistory: (serialized)->
@window.history.load serialized |
Forms | |
browser.field(selector) : ElementFind and return an input field ( | field: (selector)-> |
If the field has already been queried, return itself | if selector instanceof HTML.Element
return selector |
Try more specific selector first. | if field = @querySelector(selector)
return field if field.tagName == "INPUT" || field.tagName == "TEXTAREA" || field.tagName == "SELECT" |
Use field name (case sensitive). | if field = @querySelector(":input[name='#{selector}']")
return field |
Try finding field from label. | for label in @querySelectorAll("label")
if label.textContent.trim() == selector |
Label can either reference field or enclose it | if for_attr = label.getAttribute("for")
return @document.getElementById(for_attr)
else
return label.querySelector(":input")
return |
browser.fill(selector, value, callback) => thisFill in a field: input field or text area. selector - CSS selector, field name or text of the field label value - Field value Without callback, returns this. | fill: (selector, value, callback)->
field = @field(selector)
if field && (field.tagName == "TEXTAREA" || (field.tagName == "INPUT"))
if field.getAttribute("input")
throw new Error("This INPUT field is disabled")
if field.getAttribute("readonly")
throw new Error("This INPUT field is readonly")
field.value = value
@fire "change", field, callback
unless callback
return this
else
throw new Error("No INPUT matching '#{selector}'")
_setCheckbox: (selector, value, callback)->
field = @field(selector)
if field && field.tagName == "INPUT" && field.type == "checkbox"
if field.getAttribute("input")
throw new Error("This INPUT field is disabled")
if field.getAttribute("readonly")
throw new Error("This INPUT field is readonly")
if field.checked ^ value
@fire "click", field, callback
else if callback
callback null, false
else
return this
else
throw new Error("No checkbox INPUT matching '#{selector}'") |
browser.check(selector, callback) => thisChecks a checkbox. selector - CSS selector, field name or text of the field label Without callback, returns this. | check: (selector, callback)->
@_setCheckbox selector, true, callback |
browser.uncheck(selector, callback) => thisUnchecks a checkbox. selector - CSS selector, field name or text of the field label Without callback, returns this. | uncheck: (selector, callback)->
@_setCheckbox selector, false, callback |
browser.choose(selector, callback) => thisSelects a radio box option. selector - CSS selector, field value or text of the field label Without callback, returns this. | choose: (selector, callback)->
field = @field(selector) || @field("input[type=radio][value=\"#{escape(selector)}\"]")
if field && field.tagName == "INPUT" && field.type == "radio" && field.form
if !field.checked
radios = @querySelectorAll(":radio[name='#{field.getAttribute("name")}']", field.form)
for radio in radios
radio.checked = false unless radio.getAttribute("disabled") || radio.getAttribute("readonly")
field.checked = true
@fire "click", field
@fire "change", field, callback
else
@fire "click", field, callback
unless callback
return this
else
throw new Error("No radio INPUT matching '#{selector}'")
_findOption: (selector, value)->
field = @field(selector)
if field && field.tagName == "SELECT"
if field.getAttribute("disabled")
throw new Error("This SELECT field is disabled")
if field.getAttribute("readonly")
throw new Error("This SELECT field is readonly")
for option in field.options
if option.value == value
return option
for option in field.options
if option.label == value
return option
throw new Error("No OPTION '#{value}'")
else
throw new Error("No SELECT matching '#{selector}'") |
browser.attach(selector, filename, callback) => thisAttaches a file to the specified input field. The second argument is the file name. Without callback, returns this. | attach: (selector, filename, callback)->
field = @field(selector)
if field && field.tagName == "INPUT" && field.type == "file"
field.value = filename
@fire "change", field, callback
unless callback
return this
else
throw new Error("No file INPUT matching '#{selector}'") |
browser.select(selector, value, callback) => thisSelects an option. selector - CSS selector, field name or text of the field label value - Value (or label) or option to select Without callback, returns this. | select: (selector, value, callback)->
option = @_findOption(selector, value)
@selectOption option, callback |
browser.selectOption(option, callback) => thisSelects an option. option - option to select Without callback, returns this. | selectOption: (option, callback)->
if option && !option.getAttribute("selected")
select = @xpath("./ancestor::select", option).value[0]
option.setAttribute("selected", "selected")
@fire "change", select, callback
else if callback
callback null, false
else
return this |
browser.unselect(selector, value, callback) => thisUnselects an option. selector - CSS selector, field name or text of the field label value - Value (or label) or option to unselect Without callback, returns this. | unselect: (selector, value, callback)->
option = @_findOption(selector, value)
@unselectOption option, callback |
browser.unselectOption(option, callback) => thisUnselects an option. option - option to unselect Without callback, returns this. | unselectOption: (option, callback)->
if option && option.getAttribute("selected")
select = @xpath("./ancestor::select", option).value[0]
unless select.multiple
throw new Error("Cannot unselect in single select")
option.removeAttribute("selected")
@fire "change", select, callback
else if callback
callback null, false
else
return this |
browser.button(selector) : ElementFinds a button using CSS selector, button name or button text ( selector - CSS selector, button name or text of BUTTON element | button: (selector)->
if button = @querySelector(selector)
return button if button.tagName == "BUTTON" || button.tagName == "INPUT"
for button in @querySelectorAll("form button")
return button if button.textContent.trim() == selector
inputs = @querySelectorAll("form :submit, form :reset, form :button")
for input in inputs
return input if input.name == selector
for input in inputs
return input if input.value == selector
return |
browser.pressButton(selector, callback)Press a button (button element or input of type selector - CSS selector, button name or text of BUTTON element callback - Called with two arguments: null and browser | pressButton: (selector, callback)->
if button = @button(selector)
if button.getAttribute("disabled")
throw new Error("This button is disabled")
else
@fire "click", button, callback
else
throw new Error("No BUTTON '#{selector}'") |
Cookies and storage | |
browser.cookies(domain, path) => CookiesReturns all the cookies for this domain/path. Path defaults to "/". | cookies: (domain, path)->
return @_cookies.access(domain, path)
|
browser.saveCookies() => StringSave cookies to a text string. You can use this to load them back later on using | saveCookies: ->
@_cookies.save() |
browser.loadCookies(String)Load cookies from a text string (e.g. previously created using | loadCookies: (serialized)->
@_cookies.load serialized |
brower.localStorage(host) => StorageReturns local Storage based on the document origin (hostname/port). This is the same storage area you can access from any document of that origin. | localStorage: (host)->
return @_storages.local(host) |
brower.sessionStorage(host) => StorageReturns session Storage based on the document origin (hostname/port). This is the same storage area you can access from any document of that origin. | sessionStorage: (host)->
return @_storages.session(host) |
browser.saveStorage() => StringSave local/session storage to a text string. You can use this to load the data later on using
| saveStorage: ->
@_storages.save()
|
browser.loadStorage(String)Load local/session stroage from a text string (e.g. previously created using | loadStorage: (serialized)->
@_storages.load serialized |
Scripts | |
browser.evaluate(function) : Objectbrowser.evaluate(code, filename) : ObjectEvaluates a JavaScript expression in the context of the current window and returns the result. When evaluating external script, also include filename. You can also use this to evaluate a function in the context of the window: for timers and asynchronous callbacks (e.g. XHR). | evaluate: (code, filename)->
return @window._evaluate code, filename |
Interaction | |
browser.onalert(fn)Called by | onalert: (fn)->
@_interact.onalert fn |
browser.onconfirm(question, response)browser.onconfirm(fn)The first form specifies a canned response to return when The response to the question can be true or false, so all canned responses are converted to either value. If no response available, returns false. | onconfirm: (question, response)->
@_interact.onconfirm question, response |
browser.onprompt(message, response)browser.onprompt(fn)The first form specifies a canned response to return when The response to a prompt can be any value (converted to a string), false to indicate the user cancelled the prompt (returning null), or nothing to have the prompt return the default value or an empty string. | onprompt: (message, response)->
@_interact.onprompt message, response |
browser.prompted(message) => booleanReturns true if user was prompted with that message ( | prompted: (message)->
@_interact.prompted(message) |
Debugging | |
browser.viewInBrowser(name?)Views the current document in a real Web browser. Uses the default system browser on OS X, BSD and Linux. Probably errors on Windows. | viewInBrowser: (browser)->
require("./bcat").bcat @html() |
browser.lastRequest => HTTPRequestReturns the last request sent by this browser. The object will have the properties url, method, headers, and body. | @prototype.__defineGetter__ "lastRequest", ->
return @resources.last?.request
|
browser.lastResponse => HTTPResponseReturns the last response received by this browser. The object will have the properties url, status, headers and body. Long bodies may be truncated. | @prototype.__defineGetter__ "lastResponse", ->
return @resources.last?.response
|
browser.lastError => ObjectReturns the last error received by this browser in lieu of response. | @prototype.__defineGetter__ "lastError", ->
return @resources.last?.error |
Zombie can spit out messages to help you figure out what's going on as your code executes. To spit a message to the console when running in debug mode, call this method with one or more values (same as
For example: browser.log("Opening page:", url); browser.log(function() { return "Opening page: " + url }); | log: ->
if @debug
values = ["Zombie:"]
if typeof arguments[0] == "function"
try
values.push arguments[0]()
catch ex
values.push ex
else
values.push arg for arg in arguments
console.log.apply null, values |
Dump information to the consolt: Zombie version, current URL, history, cookies, event loop, etc. Useful for debugging and submitting error reports. | dump: ->
indent = (lines)-> lines.map((l) -> " #{l}\n").join("")
console.log "Zombie: #{VERSION}\n"
console.log "URL: #{@window.location.href}"
console.log "History:\n#{indent @window.history.dump()}"
console.log "Cookies:\n#{indent @_cookies.dump()}"
console.log "Storage:\n#{indent @_storages.dump()}"
console.log "Eventloop:\n#{indent @_eventloop.dump()}"
if @document
html = @document.outerHTML
html = html.slice(0, 497) + "..." if html.length > 497
console.log "Document:\n#{indent html.split("\n")}"
else
console.log "No document" unless @document
class Screen
constructor: ->
@width = 1280
@height = 800
@left = 0
@top = 0
@prototype.__defineGetter__ "availLeft", -> 0
@prototype.__defineGetter__ "availTop", -> 0
@prototype.__defineGetter__ "availWidth", -> @width
@prototype.__defineGetter__ "availHeight", -> @height
@prototype.__defineGetter__ "colorDepth", -> 24
@prototype.__defineGetter__ "pixelDepth", -> 24
exports.Browser = Browser
exports.version = VERSION # Backwards compatible
exports.VERSION = VERSION
|