capybara-chrome 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,204 @@
1
+ module Capybara::Chrome
2
+
3
+ # Chrome Remote Debugging Protocol (RDP) Client
4
+ class RDPClient
5
+ require "open-uri"
6
+
7
+ include Debug
8
+
9
+ attr_reader :response_events, :response_messages, :loader_ids, :listen_mutex, :handler_calls, :ws, :handlers, :browser
10
+
11
+ def initialize(chrome_host:, chrome_port:, browser:)
12
+ @chrome_host = chrome_host
13
+ @chrome_port = chrome_port
14
+ @browser = browser
15
+ @last_id = 0
16
+ @ws = nil
17
+ @ws_thread = nil
18
+ @ws_mutex = Mutex.new
19
+ @handlers = Hash.new { |hash, key| hash[key] = [] }
20
+ @listen_mutex = Mutex.new
21
+ @response_messages = {}
22
+ @response_events = []
23
+ @read_mutex = Mutex.new
24
+ @handler_mutex = Mutex.new
25
+ @loader_ids = []
26
+ @handler_calls = []
27
+ end
28
+
29
+ def reset
30
+ @calling_handlers = false
31
+ response_messages.clear
32
+ response_events.clear
33
+ loader_ids.clear
34
+ end
35
+
36
+ def generate_unique_id
37
+ @last_id += 1
38
+ end
39
+
40
+ def send_cmd!(command, params={})
41
+ debug command, params
42
+ msg_id = generate_unique_id
43
+ send_msg({method: command, params: params, id: msg_id}.to_json)
44
+ msg_id
45
+ end
46
+
47
+ # Errno::EPIPE
48
+ def send_cmd(command, params={})
49
+ msg_id = send_cmd!(command, params)
50
+
51
+ debug "waiting #{command} #{msg_id}"
52
+ msg = nil
53
+ begin
54
+ until msg = @response_messages[msg_id]
55
+ read_and_process(1)
56
+ end
57
+ @response_messages.delete msg_id
58
+ rescue Timeout::Error
59
+ puts "TimeoutError #{command} #{params.inspect} #{msg_id}"
60
+ send_cmd! "Runtime.terminateExecution"
61
+ puts "Recovering"
62
+ recover_chrome_crash
63
+ raise ResponseTimeoutError
64
+ rescue WebSocketError => e
65
+ puts "send_cmd received websocket error #{e.inspect}"
66
+ recover_chrome_crash
67
+ raise e
68
+ rescue Errno::EPIPE, EOFError => e
69
+ puts "send_cmd received EPIPE or EOF error #{e.inspect}"
70
+ recover_chrome_crash
71
+ raise e
72
+ rescue => e
73
+ puts "send_cmd caught error #{e.inspect} when issuing #{command} #{params.inspect}"
74
+ puts caller
75
+ raise e
76
+ end
77
+ return msg["result"]
78
+ end
79
+
80
+ def recover_chrome_crash
81
+ $stderr.puts "Chrome Crashed... #{Capybara::Chrome.wants_to_quit.inspect} #{::RSpec.wants_to_quit.inspect}" unless Capybara::Chrome.wants_to_quit
82
+ browser.restart_chrome
83
+ browser.start_remote
84
+ end
85
+
86
+ def send_msg(msg)
87
+ retries ||= 0
88
+ ws.send_msg(msg)
89
+ rescue Errno::EPIPE, EOFError => exception
90
+ retries += 1
91
+ recover_chrome_crash
92
+ if retries < 5 && !::Capybara::Chrome.wants_to_quit
93
+ retry
94
+ else
95
+ raise exception
96
+ end
97
+ end
98
+
99
+ def on(event_name, &block)
100
+ handlers[event_name] << block
101
+ end
102
+
103
+ def wait_for(event_name, timeout=Capybara.default_max_wait_time)
104
+ @response_events.clear
105
+ msg = nil
106
+ loop do
107
+ msgs = @response_events.select {|v| v["method"] == event_name}
108
+ if msgs.any?
109
+ if block_given?
110
+ do_return = msgs.detect do |m|
111
+ val = yield m["params"]
112
+ end
113
+ if do_return
114
+ msg = do_return.dup
115
+ @response_events.delete do_return
116
+ break
117
+ else
118
+ read_and_process(1)
119
+ next
120
+ end
121
+ else
122
+ msg = msgs.first.dup
123
+ msgs.each {|m| @response_events.delete m}
124
+ break
125
+ end
126
+ else
127
+ end
128
+ read_and_process(1)
129
+ end
130
+ return msg && msg["params"]
131
+ rescue Timeout::Error
132
+ nil
133
+ end
134
+
135
+ def process_messages
136
+ n = 0
137
+ while @ws.messages.any? do
138
+ n += 1
139
+ msg_raw = @ws.messages.shift
140
+ if msg_raw
141
+ msg = JSON.parse(msg_raw)
142
+ if msg["method"]
143
+ hs = handlers[msg["method"]]
144
+ if hs.any?
145
+ @handler_calls << [msg["method"], msg["params"]]
146
+ end
147
+ @response_events << msg
148
+ else
149
+ @response_messages[msg["id"]] = msg
150
+ if msg["exceptionDetails"]
151
+ puts JSException.new(val["exceptionDetails"]["exception"].inspect)
152
+ end
153
+ end
154
+ else
155
+ p ["no msg_raw", msg_raw]
156
+ end
157
+ end
158
+ n
159
+ end
160
+
161
+ def read_and_process(timeout=0)
162
+ return unless Thread.current == Thread.main
163
+ ready = select [@ws.socket.io], [], [], timeout
164
+ if ready
165
+ @ws.parse_input
166
+ process_messages
167
+ end
168
+ if !@calling_handlers
169
+ @calling_handlers = true
170
+ while obj = @handler_calls.shift do
171
+ handlers[obj[0]].each {|h| h.call obj[1]}
172
+ end
173
+ @calling_handlers = false
174
+ end
175
+ end
176
+
177
+ def discover_ws_url
178
+ response = open("http://#{@chrome_host}:#{@chrome_port}/json")
179
+ data = JSON.parse(response.read)
180
+ first_page = data.detect {|e| e["type"] == "page"}
181
+ @ws_url = first_page["webSocketDebuggerUrl"]
182
+ end
183
+
184
+ def start
185
+ browser.wait_for_chrome
186
+ browser.with_retry do
187
+ discover_ws_url
188
+ end
189
+ @ws = RDPWebSocketClient.new @ws_url
190
+ send_cmd! "Network.enable"
191
+ send_cmd! "Network.clearBrowserCookies"
192
+ send_cmd! "Page.enable"
193
+ send_cmd! "DOM.enable"
194
+ send_cmd! "CSS.enable"
195
+ send_cmd! "Page.setDownloadBehavior", behavior: "allow", downloadPath: Capybara::Chrome.configuration.download_path
196
+ helper_js = File.expand_path(File.join("..", "..", "chrome_remote_helper.js"), File.dirname(__FILE__))
197
+ send_cmd! "Page.addScriptToEvaluateOnNewDocument", source: File.read(helper_js)
198
+
199
+ Thread.abort_on_exception = true
200
+ return
201
+ end
202
+ end
203
+
204
+ end
@@ -0,0 +1,29 @@
1
+ module Capybara::Chrome
2
+ class RDPSocket
3
+ READ_LEN = 4096
4
+ attr_reader :url, :io
5
+
6
+ def initialize(url)
7
+ uri = URI.parse(url)
8
+ @url = uri.to_s
9
+ @io = TCPSocket.new(uri.host, uri.port)
10
+ end
11
+
12
+ def write(data)
13
+ io.sendmsg data
14
+ end
15
+
16
+ def read
17
+ buf = ""
18
+ loop do
19
+ buf << io.recv_nonblock(READ_LEN)
20
+ end
21
+ rescue IO::EAGAINWaitReadable
22
+ if buf.size == 0
23
+ puts "buf is #{buf.size}"
24
+ puts caller[0..10]
25
+ end
26
+ buf
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,51 @@
1
+ module Capybara::Chrome
2
+ class RDPWebSocketClient
3
+ attr_reader :socket, :driver, :messages, :status
4
+
5
+ def initialize(url)
6
+ @socket = RDPSocket.new(url)
7
+ @driver = ::WebSocket::Driver.client(socket)
8
+
9
+ @messages = []
10
+ @status = :closed
11
+
12
+ setup_driver
13
+ start_driver
14
+ end
15
+
16
+ def send_msg(msg)
17
+ driver.text msg
18
+ end
19
+
20
+ def parse_input
21
+ @driver.parse(@socket.read)
22
+ end
23
+
24
+ private
25
+
26
+ def setup_driver
27
+ driver.on(:message) do |e|
28
+ messages << e.data
29
+ end
30
+
31
+ driver.on(:error) do |e|
32
+ raise WebSocketError.new e.message
33
+ end
34
+
35
+ driver.on(:close) do |e|
36
+ raise "closed"
37
+ @status = :closed
38
+ end
39
+
40
+ driver.on(:open) do |e|
41
+ @status = :open
42
+ end
43
+ end
44
+
45
+ def start_driver
46
+ driver.start
47
+ select [socket.io]
48
+ parse_input until status == :open
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,15 @@
1
+ module Capybara::Chrome
2
+ module DumbTimeout
3
+ class Error < StandardError
4
+ end
5
+
6
+ def self.timeout(n)
7
+ start_at = Time.now
8
+ loop do
9
+ raise Error.new("waited #{n}s") if (Time.now - start_at).to_i >= n
10
+ yield
11
+ end
12
+ end
13
+
14
+ end
15
+ end
@@ -0,0 +1,109 @@
1
+ module Capybara::Chrome
2
+ module Service
3
+
4
+ # "--disable-gpu",
5
+ # '--js-flags="--max-old-space-size=500"',
6
+ CHROME_ARGS = [
7
+ "--headless",
8
+ "--enable-automation",
9
+ "--crash-dumps-dir=/tmp",
10
+ "--disable-background-networking",
11
+ "--disable-background-timer-throttling",
12
+ "--disable-breakpad",
13
+ "--disable-client-side-phishing-detection",
14
+ "--disable-default-apps",
15
+ "--disable-dev-shm-usage",
16
+ "--disable-extensions",
17
+ "--disable-features=site-per-process",
18
+ "--disable-hang-monitor",
19
+ "--disable-popup-blocking",
20
+ "--disable-prompt-on-repost",
21
+ "--disable-sync",
22
+ "--disable-translate",
23
+ "--metrics-recording-only",
24
+ "--no-first-run",
25
+ "--no-pings",
26
+ "--safebrowsing-disable-auto-update",
27
+ "--hide-scrollbars",
28
+ "--mute-audio",
29
+ ]
30
+
31
+ def chrome_pid
32
+ @chrome_pid
33
+ end
34
+
35
+ def start_chrome
36
+ return if chrome_running?
37
+ debug "Starting Chrome", chrome_path, chrome_args
38
+ @chrome_pid = Process.spawn chrome_path, *chrome_args, :out=>"/dev/null"
39
+ at_exit { stop_chrome }
40
+ end
41
+
42
+ def stop_chrome
43
+ Process.kill "TERM", chrome_pid rescue nil
44
+ end
45
+
46
+ def restart_chrome
47
+ stop_chrome
48
+ if chrome_running?
49
+ @chrome_port = find_available_port(@chrome_host)
50
+ end
51
+ start_chrome
52
+ end
53
+
54
+ def wait_for_chrome
55
+ running = false
56
+ while !running
57
+ running = chrome_running?
58
+ sleep 0.02
59
+ end
60
+ end
61
+
62
+ def chrome_running?
63
+ socket = TCPSocket.new(@chrome_host, @chrome_port) rescue false
64
+ socket.close if socket
65
+ !!socket
66
+ end
67
+
68
+ def chrome_path
69
+ case os
70
+ when :macosx
71
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
72
+ when :linux
73
+ # /opt/google/chrome/chrome
74
+ "google-chrome-stable"
75
+ end
76
+ end
77
+
78
+ def chrome_args
79
+ CHROME_ARGS + ["--remote-debugging-port=#{@chrome_port}"]
80
+ end
81
+
82
+ def os
83
+ @os ||= (
84
+ host_os = RbConfig::CONFIG['host_os']
85
+ case host_os
86
+ when /mswin|msys|mingw|cygwin|bccwin|wince|emc/
87
+ :windows
88
+ when /darwin|mac os/
89
+ :macosx
90
+ when /linux/
91
+ :linux
92
+ when /solaris|bsd/
93
+ :unix
94
+ else
95
+ raise Error::WebDriverError, "unknown os: #{host_os.inspect}"
96
+ end
97
+ )
98
+ end
99
+
100
+ def find_available_port(host)
101
+ sleep rand * 0.7 # slight delay to account for concurrent browsers
102
+ server = TCPServer.new(host, 0)
103
+ server.addr[1]
104
+ ensure
105
+ server.close if server
106
+ end
107
+
108
+ end
109
+ end
@@ -0,0 +1,5 @@
1
+ module Capybara
2
+ module Chrome
3
+ VERSION = "0.1.22"
4
+ end
5
+ end
@@ -0,0 +1,340 @@
1
+ window.ChromeRemotePageLoaded = document.readyState == "complete"
2
+ window.ChromeRemoteHelper = {
3
+ DOMContentLoaded: document.readyState == "interactive" || document.readyState == "complete",
4
+ windowLoaded: document.readyState == "complete",
5
+ windowUnloading: false,
6
+ nextIndex: 0,
7
+ nodes: {},
8
+ nodeClicks: {},
9
+ TEXT_TYPES: ["date", "email", "number", "password", "search", "tel", "text", "textarea", "url"],
10
+ windowWaitPromise: null,
11
+
12
+ registerNode: function(node) {
13
+ this.nextIndex++;
14
+ this.nodes[this.nextIndex] = node;
15
+ return this.nextIndex;
16
+ },
17
+
18
+ waitPromise: function(truthFn, delay) {
19
+ var ms = 0;
20
+ var intId;
21
+ var p = new Promise(function(resolve, reject) {
22
+ var truthy = truthFn();
23
+ if (truthy) {
24
+ resolve(truthy);
25
+ } else if (this.windowUnloading) {
26
+ if (intId) {
27
+ window.clearInterval(intId);
28
+ }
29
+ resolve(false);
30
+ } else {
31
+ intId = window.setInterval(function() {
32
+ truthy = truthFn(intId);
33
+ ms += delay;
34
+ if (truthy || ms > 500) {
35
+ window.clearInterval(intId);
36
+ resolve(truthy);
37
+ } else if (this.windowUnloading) {
38
+ resolve(false);
39
+ } else {
40
+ console.log("not truthy", truthy, this);
41
+ }
42
+ }.bind(this), delay);
43
+ }
44
+ }.bind(this));
45
+ return p;
46
+ },
47
+
48
+ waitUnload: function() {
49
+ var ms = 0;
50
+ var delay = 1;
51
+ var intId;
52
+ var p = new Promise(function(resolve, reject) {
53
+ if (this.windowUnloading) {
54
+ resolve(true);
55
+ }
56
+ var fn = function() {
57
+ ms += delay;
58
+ if (ms >= 100) {
59
+ //clearInterval(intId);
60
+ return true;
61
+ } else {
62
+ if (this.windowUnloading) {
63
+ // clearInterval(intId);
64
+ return true;
65
+ } else {
66
+ return false;
67
+ }
68
+ }
69
+ }.bind(this);
70
+ var redo = function() {
71
+ if (fn()) {
72
+ return resolve(true);
73
+ } else {
74
+ window.setTimeout(redo, delay);
75
+ }
76
+ }.bind(this);
77
+ redo();
78
+
79
+ // intId = window.setInterval(, delay);
80
+ }.bind(this));
81
+ return p;
82
+ },
83
+
84
+ waitWindowLoaded: function() {
85
+ var ms = 0;
86
+ var delay = 0;
87
+ this.windowWaitPromise = this.waitPromise(function() {
88
+ return this.windowLoaded;
89
+ }.bind(this), delay);
90
+ return this.windowWaitPromise;
91
+ },
92
+
93
+ waitDOMContentLoaded: function() {
94
+ return this.waitPromise(function() {
95
+ return this.DOMContentLoaded;
96
+ }.bind(this), 5);
97
+ },
98
+
99
+ findCss: function(query) {
100
+ return this.findCssRelativeTo(document, query);
101
+ },
102
+
103
+ findCssWithin: function (index, query) {
104
+ return this.findCssRelativeTo(this.getNode(index), query);
105
+ },
106
+
107
+ findCssRelativeTo: function(reference, query) {
108
+ return this.waitDOMContentLoaded().then(function() {
109
+ var results = [];
110
+ var list = reference.querySelectorAll(query);
111
+ for (i = 0; i < list.length; i++) {
112
+ results.push(this.registerNode(list[i]));
113
+ }
114
+ return results.join(",");
115
+ }.bind(this));
116
+ },
117
+
118
+ findXPath: function(query) {
119
+ return this.findXpathRelativeTo(document, query);
120
+ },
121
+
122
+ findXPathWithin: function(index, query) {
123
+ return this.findXpathRelativeTo(this.getNode(index), query);
124
+ },
125
+
126
+ findXpathRelativeTo: function(reference, query) {
127
+ return this.waitDOMContentLoaded().then(function() {
128
+ var iterator = document.evaluate(query, reference, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
129
+ var node;
130
+ var results = [];
131
+ while (node = iterator.iterateNext()) {
132
+ results.push(this.registerNode(node));
133
+ }
134
+ return results.join(",");
135
+ }.bind(this));
136
+ },
137
+
138
+ getXPathNode: function(node, path) {
139
+ path = path || [];
140
+ if (node.parentNode) {
141
+ path = this.getXPathNode(node.parentNode, path);
142
+ }
143
+
144
+ var first = node;
145
+ while (first.previousSibling)
146
+ first = first.previousSibling;
147
+
148
+ var count = 0;
149
+ var index = 0;
150
+ var iter = first;
151
+ while (iter) {
152
+ if (iter.nodeType == 1 && iter.nodeName == node.nodeName)
153
+ count++;
154
+ if (iter.isSameNode(node))
155
+ index = count;
156
+ iter = iter.nextSibling;
157
+ continue;
158
+ }
159
+
160
+ if (node.nodeType == 1)
161
+ path.push(node.nodeName.toLowerCase() + (node.id ? "[@id='"+node.id+"']" : count > 1 ? "["+index+"]" : ''));
162
+
163
+ return path;
164
+ },
165
+
166
+ nodePathForNode: function(index) {
167
+ return this.pathForNode(this.getNode(index));
168
+ },
169
+
170
+ pathForNode: function(node) {
171
+ return "/" + this.getXPathNode(node).join("/");
172
+ },
173
+
174
+ getNode: function(index) {
175
+ var node = this.nodes[index];
176
+ if (!node) {
177
+ throw new NodeNotFoundError("No node found with id:"+index+". Registered nodes:"+Object.keys(this.nodes).length);
178
+ }
179
+ return node;
180
+ },
181
+
182
+ onSelf: function(index, script) {
183
+ var node = this.getNode(index);
184
+ // console.log("onSelf " + index + " " + node.tagName + " " + (node.parentElement && node.parentElement.tagName) + " " + script);
185
+ var fn = Function("'use strict';"+script).bind(node)
186
+ var pp = new Promise(function(resolve) { resolve(fn()); });
187
+ return pp;
188
+ },
189
+
190
+ // args should be an array
191
+ onSelfValue: function(index, meth, args) {
192
+ var node = this.getNode(index);
193
+ var val = node[meth];
194
+ if (typeof val === "function") {
195
+ val.apply(node, args);
196
+ } else {
197
+ return val;
198
+ }
199
+ },
200
+
201
+ dispatchEvent: function(node, eventName) {
202
+ var eventObject;
203
+ if (eventName == "click") {
204
+ eventObject = new MouseEvent("click", {bubbles: true, cancelable: true});
205
+ } else {
206
+ eventObject = document.createEvent("HTMLEvents");
207
+ eventObject.initEvent(eventName, true, true);
208
+ }
209
+ return node.dispatchEvent(eventObject);
210
+ },
211
+
212
+ nodeSetType: function(index) {
213
+ var node = this.getNode(index);
214
+ return (node.type || node.tagName).toLowerCase();
215
+ },
216
+
217
+ nodeSet: function(index, value, type) {
218
+ var node = this.getNode(index);
219
+ if (type == "checkbox" || type == "radio") {
220
+ if (value == "true" && !node.checked) {
221
+ return node.click();
222
+ } else if (value == "false" && node.checked) {
223
+ return node.click();
224
+ }
225
+ } else {
226
+ return node.value = value;
227
+ }
228
+ },
229
+
230
+ nodeVisible: function(index) {
231
+ return this.visible(this.getNode(index));
232
+ },
233
+
234
+ visible: function(node) {
235
+ var styles = node.ownerDocument.defaultView.getComputedStyle(node);
236
+ if (styles["visibility"] == "hidden" || styles["display"] == "none" || styles["opacity"] == 0) {
237
+ return false;
238
+ }
239
+ while (node = node.parentElement) {
240
+ styles = node.ownerDocument.defaultView.getComputedStyle(node);
241
+ if (styles["display"] == "none" || styles["opacity"] == 0) {
242
+ return false;
243
+ }
244
+ }
245
+ return true;
246
+ },
247
+
248
+ nodeText: function(index) {
249
+ return this.text(this.getNode(index));
250
+ },
251
+
252
+ text: function(node) {
253
+ var type = node instanceof HTMLFormElement ? 'form' : (node.type || node.tagName).toLowerCase();
254
+ if (type == "textarea") {
255
+ return node.innerHTML;
256
+ } else {
257
+ var visible_text = node.innerText;
258
+ return typeof visible_text === "string" ? visible_text : node.textContent;
259
+ }
260
+ },
261
+
262
+ nodeIsNodeAtPosition: function(index, pos) {
263
+ return this.isNodeAtPosition(this.getNode(index), pos);
264
+ },
265
+
266
+ isNodeAtPosition: function(node, pos) {
267
+ var nodeAtPosition =
268
+ document.elementFromPoint(pos.relativeX, pos.relativeY);
269
+ var overlappingPath;
270
+
271
+
272
+ if (nodeAtPosition) {
273
+ // console.log("is node at position" + nodeAtPosition.tagName)
274
+ overlappingPath = this.pathForNode(nodeAtPosition)
275
+ }
276
+
277
+ if (!this.isNodeOrChildAtPosition(node, pos, nodeAtPosition)) {
278
+ // console.log("Would throw " + overlappingPath + " " + this.pathForNode(node))
279
+ return false;
280
+ }
281
+
282
+ return true;
283
+ },
284
+
285
+ isNodeOrChildAtPosition: function(expectedNode, pos, currentNode) {
286
+ if (currentNode == expectedNode) {
287
+ return true;
288
+ } else if (currentNode) {
289
+ return this.isNodeOrChildAtPosition(
290
+ expectedNode,
291
+ pos,
292
+ currentNode.parentNode
293
+ );
294
+ } else {
295
+ return false;
296
+ }
297
+ },
298
+
299
+ nodeGetDimensions: function(index) {
300
+ return this.getDimensions(this.getNode(index));
301
+ },
302
+
303
+ getDimensions: function(node) {
304
+ return JSON.stringify(node.getBoundingClientRect());
305
+ },
306
+
307
+ // don't attach state to the node because the node can go away after click
308
+ attachClickListener: function(index) {
309
+ var node = this.getNode(index);
310
+ this.nodeClicks[index] = false;
311
+ var fn = function() {
312
+ this.nodeClicks[index] = true;
313
+ node.removeEventListener("click", fn);
314
+ }.bind(this);
315
+ node.addEventListener("click", fn);
316
+ },
317
+
318
+ nodeVerifyClicked: function(index) {
319
+ return this.nodeClicks[index];
320
+ }
321
+ }
322
+
323
+ document.addEventListener("DOMContentLoaded", function() {
324
+ ChromeRemoteHelper.DOMContentLoaded = true;
325
+ });
326
+
327
+ window.addEventListener("load", function() {
328
+ ChromeRemoteHelper.windowLoaded = true;
329
+ ChromeRemotePageLoaded = true;
330
+ });
331
+
332
+ window.addEventListener("beforeunload", function() {
333
+ ChromeRemoteHelper.windowUnloading = true;
334
+ ChromeRemoteHelper.windowLoaded = true;
335
+ });
336
+
337
+ window.addEventListener("unload", function() {
338
+ });
339
+
340
+ class NodeNotFoundError extends Error{}