netzke-testing 0.9.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (77) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +17 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE.txt +22 -0
  5. data/README.md +106 -0
  6. data/Rakefile +1 -0
  7. data/app/assets/javascripts/netzke/.keep +0 -0
  8. data/app/assets/javascripts/netzke/testing/helpers/actions.js.coffee +74 -0
  9. data/app/assets/javascripts/netzke/testing/helpers/expectations.js.coffee +14 -0
  10. data/app/assets/javascripts/netzke/testing/helpers/form.js.coffee +7 -0
  11. data/app/assets/javascripts/netzke/testing/helpers/grid.js.coffee +107 -0
  12. data/app/assets/javascripts/netzke/testing/helpers/queries.js.coffee +88 -0
  13. data/app/assets/vendor/javascripts/expect/expect.js +1253 -0
  14. data/app/assets/vendor/javascripts/mocha/mocha.js +5340 -0
  15. data/app/assets/vendor/stylesheets/mocha/mocha.css +231 -0
  16. data/app/controllers/.keep +0 -0
  17. data/app/controllers/netzke/netzke/testing_controller.rb +28 -0
  18. data/app/helpers/.keep +0 -0
  19. data/app/helpers/netzke_testing_helper.rb +2 -0
  20. data/app/mailers/.keep +0 -0
  21. data/app/models/.keep +0 -0
  22. data/app/views/.keep +0 -0
  23. data/app/views/layouts/netzke/testing.html.erb +35 -0
  24. data/app/views/netzke/index.html.erb +2 -0
  25. data/config/routes.rb +8 -0
  26. data/lib/netzke/testing/engine.rb +6 -0
  27. data/lib/netzke/testing/helpers.rb +47 -0
  28. data/lib/netzke/testing/version.rb +5 -0
  29. data/lib/netzke/testing.rb +22 -0
  30. data/lib/netzke-testing.rb +1 -0
  31. data/netzke-testing.gemspec +23 -0
  32. data/test/controllers/netzke_testing_controller_test.rb +9 -0
  33. data/test/dummy/README.rdoc +28 -0
  34. data/test/dummy/Rakefile +6 -0
  35. data/test/dummy/app/assets/images/.keep +0 -0
  36. data/test/dummy/app/assets/javascripts/application.js +13 -0
  37. data/test/dummy/app/assets/stylesheets/application.css +13 -0
  38. data/test/dummy/app/components/foo.rb +6 -0
  39. data/test/dummy/app/controllers/application_controller.rb +5 -0
  40. data/test/dummy/app/controllers/concerns/.keep +0 -0
  41. data/test/dummy/app/helpers/application_helper.rb +2 -0
  42. data/test/dummy/app/mailers/.keep +0 -0
  43. data/test/dummy/app/models/.keep +0 -0
  44. data/test/dummy/app/models/concerns/.keep +0 -0
  45. data/test/dummy/app/views/layouts/application.html.erb +14 -0
  46. data/test/dummy/bin/bundle +3 -0
  47. data/test/dummy/bin/rails +4 -0
  48. data/test/dummy/bin/rake +4 -0
  49. data/test/dummy/config/application.rb +24 -0
  50. data/test/dummy/config/boot.rb +5 -0
  51. data/test/dummy/config/database.yml +25 -0
  52. data/test/dummy/config/environment.rb +5 -0
  53. data/test/dummy/config/environments/development.rb +23 -0
  54. data/test/dummy/config/environments/production.rb +80 -0
  55. data/test/dummy/config/environments/test.rb +36 -0
  56. data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
  57. data/test/dummy/config/initializers/filter_parameter_logging.rb +4 -0
  58. data/test/dummy/config/initializers/inflections.rb +16 -0
  59. data/test/dummy/config/initializers/mime_types.rb +5 -0
  60. data/test/dummy/config/initializers/secret_token.rb +12 -0
  61. data/test/dummy/config/initializers/session_store.rb +3 -0
  62. data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
  63. data/test/dummy/config/locales/en.yml +23 -0
  64. data/test/dummy/config/routes.rb +58 -0
  65. data/test/dummy/config.ru +4 -0
  66. data/test/dummy/lib/assets/.keep +0 -0
  67. data/test/dummy/log/.keep +0 -0
  68. data/test/dummy/public/404.html +58 -0
  69. data/test/dummy/public/422.html +58 -0
  70. data/test/dummy/public/500.html +57 -0
  71. data/test/dummy/public/favicon.ico +0 -0
  72. data/test/dummy/spec/javascripts/foo.js.coffee +3 -0
  73. data/test/helpers/netzke_testing_helper_test.rb +4 -0
  74. data/test/integration/navigation_test.rb +10 -0
  75. data/test/netzke_testing_test.rb +7 -0
  76. data/test/test_helper.rb +15 -0
  77. metadata +193 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: f6a8e145a80cbd76fe5872a58d199b5c0c3f6002
4
+ data.tar.gz: e7bef8a910494ec36fc1d4979f01f990bb1baac7
5
+ SHA512:
6
+ metadata.gz: ecb548ed750a7cdaa03b3a8eb517524c220de8f4a0bc8f845a5a34ad8425773afdb3c8c595633d5d5d240752d2d827b8f3c825ebe06dfe972ed906b622acc3a2
7
+ data.tar.gz: 8db04d54ff7ebbd9c731297c783dc02ff7a2faea022512bdef617b49b01ef339ea22dc99cd23528c40641e2693db5b4c422a53bfdd105eefafcb1fc6e086d422
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in netzke-testing.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Max Gorin
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # Netzke Testing
2
+
3
+ This gem helps with development and testing of Netzke components. In parcticular, it helps you with:
4
+
5
+ * isolated component development
6
+ * client-side testing of components with Mocha and Expect.js
7
+
8
+ Usage:
9
+
10
+ gem 'netzke_testing'
11
+
12
+ ## Isolated component development
13
+
14
+ The gem implements a Rails engine, which (in development and test environments only) adds a route to load your
15
+ application's Netzke components individually, which can be useful for isolated development. Example (say, we have a
16
+ UserGrid component defined):
17
+
18
+ http://localhost:3000/netzke/components/UserGrid
19
+
20
+ This will load a view with UserGrid occupying the available window width, with default height of 400px. You can change
21
+ the height by providing the `height` parameter in the URL:
22
+
23
+ http://localhost:3000/netzke/components/UserGrid?height=600
24
+
25
+ ## Testing components with Mocha and Expect.js
26
+
27
+ Place the Mocha specs (written in Coffeescript) for your components inside `spec/javascripts` folder. An example spec
28
+ may look like this (in `spec/javascripts/user_grid.js.coffee`):
29
+
30
+ describe 'UserGrid', ->
31
+ it 'shows proper title', ->
32
+ grid = Ext.ComponentQuery.query('panel[id="user_grid"]')[0]
33
+ expect(grid.getHeader().title).to.eql 'Test component'
34
+
35
+ This spec can be run by appending the `spec` parameter to the url:
36
+
37
+ http://localhost:3000/netzke/components/UserGrid?spec=user_grid
38
+
39
+ Specs can be structured into directories. For example, let's say we have a namescope for admin components:
40
+
41
+ class Admin::UserGrid < Netzke::Basepack::Grid
42
+ end
43
+
44
+ It makes sense to put the corresponding specs in `spec/javascripts/admin/user_grid.js.coffee`. In this case, the URL
45
+ to run the Mocha specs will be:
46
+
47
+ http://localhost:3000/netzke/components/UserGrid?spec=admin/user_grid
48
+
49
+ ## Mocha spec helpers
50
+
51
+ The gem provides a number of helpers that may help you writing less code and make your specs look something like this:
52
+
53
+ describe 'UserGrid', ->
54
+ it 'allows instant removing of all users with a single button click', (done) ->
55
+ click button 'Remove all'
56
+ wait ->
57
+ expectToSee header 'Empty'
58
+ done()
59
+
60
+ Keep in mind the following:
61
+
62
+ * the current set of helpers is in flux, and may be drastically changed sooner than you may expect
63
+ * the helpers directly pollute the `window` namespace; if you decide you're better off without provided helpers,
64
+ specify 'no-helpers=true' as an extra URL parameter
65
+
66
+ See the [source code](TODO) for currently implemented helpers. Also, refer to other Netzke gems source code (like
67
+ netzke-core and netzke-basepack) to see examples using the helpers.
68
+
69
+ ## Testing with selenium webdriver
70
+
71
+ Generate the `netzke_mocha_spec.rb` file that will automatically run the specs that follow a certain naming convention:
72
+
73
+ rails g netzke_testing
74
+
75
+ This spec will pick up all the `*_spec.js.coffee` files from `spec/javascripts` folder and generate an `it` clause for
76
+ each of them. Let's say we want to create the spec for UserGrid. For this we name the spec file
77
+ `spec/javascripts/user_grid_spec.js.coffee`. And the other way around: when `netzke_mocha_spec.rb` finds a file called
78
+ `spec/javascripts/order_grid_spec.js.coffee`, it'll assume existance of `OrderGrid` component that should be tested.
79
+
80
+ ## Mixing client- and server-side testing code
81
+
82
+ Often we want to run some Ruby code before running the Mocha spec (e.g. to seed some test data using factories), or
83
+ after (e.g. to assert changes in the database). In this case you can create a RSpec spec that uses the `run_mocha_spec`
84
+ helper provided by the `netzke_testing` gem. Here's an example (in `spec/user_grid_spec.rb`):
85
+
86
+ require 'spec_helper'
87
+ feature GridWithDestructiveButton do
88
+ it 'allows instant removing of all records with a single button click', js: true do
89
+ 10.times { FactoryGirl.create :user }
90
+ User.count.should == 10
91
+ run_mocha_spec 'grid_with_destructive_button'
92
+ User.count.should == 0
93
+ end
94
+ end
95
+
96
+ The `run_mocha_spec` here will run a Mocha spec from `spec/grid_with_destructive_button.js.coffee`.
97
+
98
+ You can explicitely specify a component to run the spec on (in order to override the convention):
99
+
100
+ run_mocha_spec 'grid_with_destructive_button', component: 'UserGrid'
101
+
102
+ ---
103
+ Copyright (c) 2008-2013 [Max Gorin](https://twitter.com/uptomax), released under the MIT license (see LICENSE).
104
+
105
+ **Note** that Ext JS is licensed [differently](http://www.sencha.com/products/extjs/license/), and you may need to
106
+ purchase a commercial license in order to use it in your projects!
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
File without changes
@@ -0,0 +1,74 @@
1
+ Ext.Ajax.on 'beforerequest', ->
2
+ Netzke.ajaxCount = window.ajaxCount || 0
3
+ Netzke.ajaxCount += 1
4
+
5
+ Ext.Ajax.on 'requestcomplete', ->
6
+ Netzke.ajaxCount -= 1
7
+
8
+ Ext.apply window,
9
+ # Examples:
10
+ #
11
+ # wait ->
12
+ # afterAllAjaxActivityIsStopped()
13
+ #
14
+ # wait 2000, ->
15
+ # afterTwoSeconds()
16
+ wait: () ->
17
+ if typeof arguments[0] == 'function'
18
+ callback = arguments[0]
19
+ i = 0
20
+ id = setInterval ->
21
+ i += 1
22
+ if i >= 100
23
+ clearInterval(id)
24
+ callback.call()
25
+
26
+ # this way we ensure another 20ms cycle before we issue a callback
27
+ i = 100 if Netzke.ajaxCount == 0
28
+ , 100
29
+ else
30
+ delay = arguments[0]
31
+ callback = arguments[1]
32
+ setInterval ->
33
+ callback.call()
34
+ , delay
35
+
36
+ click: (cmp) ->
37
+ if Ext.isString(cmp)
38
+ throw "Could not locate " + cmp
39
+ else if (cmp.isXType) # is Ext component
40
+ if (cmp.isXType('tool'))
41
+ # a hack needed for tools
42
+ el = cmp.toolEl
43
+ else
44
+ el = cmp.getEl()
45
+
46
+ el.dom.click()
47
+ else if Ext.isElement(cmp)
48
+ cmp.click()
49
+
50
+ # Closes the first found window
51
+ closeWindow: ->
52
+ Ext.ComponentQuery.query("window[hidden=false]")[0].close()
53
+
54
+ select: (value, params, callback) ->
55
+ params ?= params
56
+ combo = params.in
57
+ if combo.isExpanded
58
+ combo.setValue combo.findRecordByDisplay value
59
+ combo.collapse()
60
+ else
61
+ combo.onTriggerClick()
62
+ if callback
63
+ wait ->
64
+ rec = combo.findRecordByDisplay value
65
+ combo.select rec
66
+ combo.fireEvent('select', combo, rec )
67
+ combo.collapse()
68
+ callback.call()
69
+ else
70
+ rec = combo.findRecordByDisplay value
71
+ combo.select rec
72
+ combo.fireEvent('select', combo, rec )
73
+ combo.collapse()
74
+
@@ -0,0 +1,14 @@
1
+ Ext.apply window,
2
+ expectToSee: (el) ->
3
+ expect(Ext.isObject(el) || Ext.isElement(el)).to.be.ok()
4
+
5
+ expectToNotSee: (el) ->
6
+ expect(Ext.isString(el)).to.be.ok()
7
+
8
+ expectDisabled: (cmp) ->
9
+ throw cmp + " not found" if Ext.isString(cmp)
10
+ expect(cmp.isDisabled()).to.be(true)
11
+
12
+ expectInvisibleBodyOf: (cmp) ->
13
+ throw cmp + " not found" if Ext.isString(cmp)
14
+ expect(cmp.body.isVisible()).to.be false
@@ -0,0 +1,7 @@
1
+ Ext.apply window,
2
+ fill: (field, params) ->
3
+ field.setValue(params.with)
4
+
5
+ expandCombo: (combo) ->
6
+ combo = Ext.ComponentQuery.query("combo{isVisible(true)}[name="+combo+"]")[0]
7
+ combo.onTriggerClick()
@@ -0,0 +1,107 @@
1
+ Ext.apply window,
2
+ grid: (title) ->
3
+ if title
4
+ Ext.ComponentQuery.query('grid[title="'+title+'"]')[0]
5
+ else
6
+ Ext.ComponentQuery.query('grid{isVisible(true)}')[0]
7
+
8
+ expandRowCombo: (field, params) ->
9
+ g = g || this.grid()
10
+ editor = g.getPlugin('celleditor')
11
+ column = g.headerCt.items.findIndex('name', field) - 1
12
+ window.editor = editor
13
+ editor.startEditByPosition({row: g.getSelectionModel().getCurrentPosition().row, column: column})
14
+ editor.activeEditor.field.onTriggerClick()
15
+
16
+ # Example:
17
+ # addRecords {title: 'Foo'}, {title: 'Bar'}, to: grid('Books'), submit: true
18
+ addRecords: ->
19
+ params = arguments[arguments.length - 1]
20
+ for record in arguments
21
+ if (record != params)
22
+ record = params.to.getStore().add(record)[0]
23
+ record.isNew = true
24
+ click button 'Apply' if params.submit
25
+
26
+ addRecord: (recordData, params) ->
27
+ params = params || []
28
+ grid = params.to || this.grid()
29
+ record = grid.getStore().add(recordData)
30
+ grid.getSelectionModel().select(grid.getStore().last())
31
+
32
+ updateRecord: (recordData, params) ->
33
+ params = params || []
34
+ grid = params.to || this.grid()
35
+ record = grid.getSelectionModel().getSelection()[0]
36
+ for key,value of recordData
37
+ record.set(key, value)
38
+
39
+ selectAssociation: (attr, value, callback) ->
40
+ expandRowCombo attr
41
+ wait ->
42
+ select value, in: combobox(attr)
43
+ # wait ->
44
+ callback.call()
45
+
46
+ valuesInColumn: (name, params) ->
47
+ params ?= {}
48
+ grid = params.in || this.grid()
49
+ out = []
50
+ grid.getStore().each (r) ->
51
+ assocValue = r.get('meta').associationValues[name]
52
+ out.push(if assocValue then assocValue else r.get(name))
53
+ out
54
+
55
+ selectAllRows: (params) ->
56
+ params ?= {}
57
+ grid = params.in || this.grid()
58
+ grid.getSelectionModel().selectAll()
59
+
60
+ # rowDisplayValues in: grid('Books'), of: grid('Books').getStore().last()
61
+ # Without parameters, assumes the first found grid and the selected row
62
+ rowDisplayValues: (params) ->
63
+ params ?= {}
64
+ grid = params.in || this.grid()
65
+ record = params.of || grid.getSelectionModel().getSelection()[0]
66
+
67
+ visibleColumns = []
68
+ Ext.each grid.columns, (c) ->
69
+ visibleColumns.push(c) if c.isVisible()
70
+
71
+ i = -1
72
+ return Ext.Array.map(Ext.DomQuery.select('tr[data-recordid="'+record.internalId+'"] td div'), (cell) ->
73
+ i++
74
+ if visibleColumns[i].attrType == 'boolean'
75
+ record.get(visibleColumns[i].name)
76
+ else
77
+ cell.innerHTML
78
+ )
79
+
80
+ # selectLastRow()
81
+ # selectLastRow in: grid('Book')
82
+ selectLastRow: (params) ->
83
+ params ?= {}
84
+ grid = params.in || this.grid()
85
+ grid.getSelectionModel().select(grid.getStore().last())
86
+
87
+ # selectFirstRow()
88
+ # selectFirstRow in: grid('Book')
89
+ selectFirstRow: (params) ->
90
+ params ?= {}
91
+ grid = params.in || this.grid()
92
+ grid.getSelectionModel().select(grid.getStore().first())
93
+
94
+ # Example:
95
+ # editLastRow {title: 'Foo', exemplars: 10}
96
+ editLastRow: ->
97
+ data = arguments[0]
98
+ grid = Ext.ComponentQuery.query("grid")[0]
99
+ store = grid.getStore()
100
+ record = store.last()
101
+ for key of data
102
+ record.set(key, data[key])
103
+
104
+ completeEditing: (g) ->
105
+ g = g || this.grid()
106
+ e = g.getPlugin('celleditor')
107
+ e.completeEdit()
@@ -0,0 +1,88 @@
1
+ # Query helpers will return a string denoting what was searched for, when a component/element itself could not be found. This can be used by other helpers to display a more informative error.
2
+ # KNOWN ISSUE: if the passed parameter contains symbols like "():,.", it results in an invalid query.
3
+ Ext.apply window,
4
+ header: (title) ->
5
+ Ext.ComponentQuery.query('header{isVisible(true)}[title="'+title+'"]')[0] || 'header ' + title
6
+
7
+ tab: (title) ->
8
+ Ext.ComponentQuery.query('tab[text="'+title+'"]')[0] || 'tab ' + title
9
+
10
+ panelWithContent: (text) ->
11
+ Ext.DomQuery.select("div.x-panel-body:contains(" + text + ")")[0] || 'panel with content ' + text
12
+
13
+ button: (text) ->
14
+ Ext.ComponentQuery.query("button{isVisible(true)}[text='"+text+"']")[0] || "button " + text
15
+
16
+ tool: (type) ->
17
+ Ext.ComponentQuery.query("tool{isVisible(true)}[type='"+type+"']")[0] || 'tool ' + type
18
+
19
+ component: (id) ->
20
+ Ext.ComponentQuery.query("panel{isVisible(true)}[id='"+id+"']")[0] || 'component ' + id
21
+
22
+ somewhere: (text) ->
23
+ Ext.DomQuery.select("*:contains(" + text + ")")[0] || 'anywhere ' + text
24
+
25
+ # used as work-around for the invalid query problem
26
+ currentPanelTitle: ->
27
+ panel = Ext.ComponentQuery.query('panel[hidden=false]')[0]
28
+ throw "Panel not found" if !panel
29
+ panel.getHeader().title
30
+
31
+ combobox: (name) ->
32
+ Ext.ComponentQuery.query("combo{isVisible(true)}[name='"+name+"']")[0] ||
33
+ 'combobox ' + name
34
+
35
+ icon: (tooltip) ->
36
+ Ext.DomQuery.select('img[data-qtip="'+tooltip+'"]')[0] || 'icon ' + tooltip
37
+
38
+ textfield: (name) ->
39
+ Ext.ComponentQuery.query("textfield{isVisible(true)}[name='"+name+"']")[0] ||
40
+ 'textfield ' + name
41
+
42
+ numberfield: (name) ->
43
+ Ext.ComponentQuery.query("numberfield{isVisible(true)}[name='"+name+"']")[0] ||
44
+ 'numberfield ' + name
45
+
46
+ datefield: (name) ->
47
+ Ext.ComponentQuery.query("datefield{isVisible(true)}[name='"+name+"']")[0] ||
48
+ 'datefield ' + name
49
+
50
+ xdatetime: (name) ->
51
+ Ext.ComponentQuery.query("xdatetime{isVisible(true)}[name='"+name+"']")[0] ||
52
+ 'xdatetime ' + name
53
+
54
+ textFieldWith: (text) ->
55
+ _componentLike "textfield", "value", text
56
+
57
+ comboboxWith: (text) ->
58
+ _componentLike "combo", "rawValue", text
59
+
60
+ textAreaWith: (text) ->
61
+ _componentLike "textareafield", "value", text
62
+
63
+ numberFieldWith: (value) ->
64
+ _componentLike "numberfield", "value", value
65
+
66
+ activeWindow: ->
67
+ Ext.WindowMgr.getActive()
68
+
69
+ dateTimeFieldWith: (value) ->
70
+ res = 'xdatetime with value ' + value
71
+ Ext.each Ext.ComponentQuery.query('xdatetime'), (item) ->
72
+ if item.getValue().toString() == (new Date(value)).toString()
73
+ res = item
74
+ return
75
+ res
76
+
77
+ dateFieldWith: (value) ->
78
+ res = 'datefield with value ' + value
79
+ Ext.each Ext.ComponentQuery.query('datefield'), (item) ->
80
+ if item.getValue().toString() == (new Date(value)).toString()
81
+ res = item
82
+ return
83
+ res
84
+
85
+ _componentLike:(type,attr,value)->
86
+ Ext.ComponentQuery.query(type+'['+attr+'='+value+']')[0] || type + " with " + attr + " '" + value + "'"
87
+ # alias
88
+ window.anywhere = window.somewhere