casperjs 1.0.0.RC1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (121) hide show
  1. data/CHANGELOG.md +289 -1
  2. data/CONTRIBUTING.md +93 -0
  3. data/CONTRIBUTORS.md +53 -0
  4. data/README.md +29 -1
  5. data/batchbin/casperjs.bat +5 -0
  6. data/bin/bootstrap.js +164 -114
  7. data/bin/casperjs +84 -0
  8. data/bin/usage.txt +2 -1
  9. data/casperjs.gemspec +7 -4
  10. data/modules/casper.js +661 -239
  11. data/modules/clientutils.js +240 -54
  12. data/modules/colorizer.js +2 -1
  13. data/modules/events.js +13 -1
  14. data/modules/http.js +70 -0
  15. data/modules/mouse.js +1 -2
  16. data/modules/pagestack.js +166 -0
  17. data/modules/tester.js +1013 -659
  18. data/modules/utils.js +519 -367
  19. data/modules/vendors/coffee-script.js +2 -2
  20. data/modules/xunit.js +56 -24
  21. data/package.json +2 -2
  22. data/rpm/casperjs.spec +203 -0
  23. data/rpm/mkrpm.sh +25 -0
  24. data/rubybin/casperjs +9 -4
  25. data/samples/bbcshots.coffee +11 -10
  26. data/samples/bbcshots.js +16 -15
  27. data/samples/cliplay.js +3 -0
  28. data/samples/customevents.js +3 -0
  29. data/samples/customlogging.coffee +17 -19
  30. data/samples/customlogging.js +26 -27
  31. data/samples/download.js +4 -1
  32. data/samples/dynamic.js +3 -0
  33. data/samples/each.js +3 -0
  34. data/samples/events.js +4 -2
  35. data/samples/extends.js +4 -1
  36. data/samples/googlelinks.coffee +4 -1
  37. data/samples/googlelinks.js +10 -3
  38. data/samples/googlematch.coffee +1 -1
  39. data/samples/googlematch.js +5 -2
  40. data/samples/googlepagination.js +4 -1
  41. data/samples/googletesting.js +3 -0
  42. data/samples/logcolor.js +3 -0
  43. data/samples/metaextract.js +3 -0
  44. data/samples/multirun.js +3 -0
  45. data/samples/screenshot.js +4 -1
  46. data/samples/statushandlers.js +4 -1
  47. data/samples/steptimeout.js +3 -0
  48. data/samples/timeout.js +4 -1
  49. data/samples/translate.coffee +23 -0
  50. data/samples/translate.js +30 -0
  51. data/tests/commands/mytest.js +14 -0
  52. data/tests/commands/script.js +3 -0
  53. data/tests/run.js +82 -37
  54. data/tests/sample_modules/config.json +1 -0
  55. data/tests/sample_modules/csmodule.coffee +1 -0
  56. data/tests/sample_modules/jsmodule.js +1 -0
  57. data/tests/selftest.js +57 -0
  58. data/tests/site/dummy.js +1 -0
  59. data/tests/site/field-array.html +14 -0
  60. data/tests/site/frame1.html +10 -0
  61. data/tests/site/frame2.html +11 -0
  62. data/tests/site/frame3.html +11 -0
  63. data/tests/site/frames.html +12 -0
  64. data/tests/site/global.html +6 -1
  65. data/tests/site/includes/include1.js +6 -0
  66. data/tests/site/includes/include2.js +6 -0
  67. data/tests/site/index.html +3 -0
  68. data/tests/site/popup.html +19 -0
  69. data/tests/site/resources.html +7 -8
  70. data/tests/site/urls.html +14 -0
  71. data/tests/suites/casper/agent.js +3 -1
  72. data/tests/suites/casper/alert.js +14 -0
  73. data/tests/suites/casper/auth.js +24 -0
  74. data/tests/suites/casper/capture.js +12 -12
  75. data/tests/suites/casper/click.js +11 -1
  76. data/tests/suites/casper/confirm.js +24 -16
  77. data/tests/suites/casper/debug.js +10 -0
  78. data/tests/suites/casper/elementattribute.js +3 -1
  79. data/tests/suites/casper/encode.js +6 -2
  80. data/tests/suites/casper/evaluate.js +78 -18
  81. data/tests/suites/casper/events.js +3 -1
  82. data/tests/suites/casper/exists.js +3 -1
  83. data/tests/suites/casper/fetchtext.js +3 -1
  84. data/tests/suites/casper/flow.coffee +1 -1
  85. data/tests/suites/casper/formfill.js +39 -4
  86. data/tests/suites/casper/frames.js +43 -0
  87. data/tests/suites/casper/global.js +9 -3
  88. data/tests/suites/casper/headers.js +41 -0
  89. data/tests/suites/casper/history.js +3 -1
  90. data/tests/suites/casper/hooks.js +3 -1
  91. data/tests/suites/casper/keys.js +15 -0
  92. data/tests/suites/casper/location.js +21 -0
  93. data/tests/suites/casper/logging.js +3 -1
  94. data/tests/suites/casper/mouseevents.js +3 -1
  95. data/tests/suites/casper/onerror.js +3 -1
  96. data/tests/suites/casper/open.js +63 -1
  97. data/tests/suites/casper/popup.js +86 -0
  98. data/tests/suites/casper/prompt.js +11 -15
  99. data/tests/suites/casper/request.js +36 -0
  100. data/tests/suites/casper/resources.coffee +5 -5
  101. data/tests/suites/casper/scripts.js +32 -0
  102. data/tests/suites/casper/start.js +3 -1
  103. data/tests/suites/casper/steps.js +4 -2
  104. data/tests/suites/casper/urls.js +21 -0
  105. data/tests/suites/casper/viewport.js +3 -1
  106. data/tests/suites/casper/visible.js +3 -1
  107. data/tests/suites/casper/wait.js +22 -12
  108. data/tests/suites/casper/xpath.js +3 -1
  109. data/tests/suites/cli.js +3 -1
  110. data/tests/suites/clientutils.js +57 -3
  111. data/tests/suites/coffee.coffee +1 -1
  112. data/tests/suites/fs.js +3 -1
  113. data/tests/suites/http_status.js +19 -3
  114. data/tests/suites/popup.js +33 -0
  115. data/tests/suites/require.js +31 -0
  116. data/tests/suites/tester.js +134 -29
  117. data/tests/suites/utils.js +108 -8
  118. data/tests/suites/xunit.js +37 -6
  119. metadata +49 -15
  120. data/modules/injector.js +0 -93
  121. data/tests/suites/injector.js +0 -64
@@ -41,6 +41,7 @@
41
41
  * Casper client-side helpers.
42
42
  */
43
43
  exports.ClientUtils = function ClientUtils(options) {
44
+ /*jshint maxstatements:40*/
44
45
  // private members
45
46
  var BASE64_ENCODE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
46
47
  var BASE64_DECODE_CHARS = new Array(
@@ -57,7 +58,7 @@
57
58
 
58
59
  // public members
59
60
  this.options = options || {};
60
-
61
+ this.options.scope = this.options.scope || document;
61
62
  /**
62
63
  * Clicks on the DOM element behind the provided selector.
63
64
  *
@@ -75,6 +76,7 @@
75
76
  * @return string
76
77
  */
77
78
  this.decode = function decode(str) {
79
+ /*jshint maxstatements:30 maxcomplexity:30 */
78
80
  var c1, c2, c3, c4, i = 0, len = str.length, out = "";
79
81
  while (i < len) {
80
82
  do {
@@ -115,6 +117,16 @@
115
117
  return out;
116
118
  };
117
119
 
120
+ /**
121
+ * Echoes something to casper console.
122
+ *
123
+ * @param String message
124
+ * @return
125
+ */
126
+ this.echo = function echo(message) {
127
+ console.log("[casper.echo] " + message);
128
+ };
129
+
118
130
  /**
119
131
  * Base64 encodes a string, even binary ones. Succeeds where
120
132
  * window.btoa() fails.
@@ -123,6 +135,7 @@
123
135
  * @return string
124
136
  */
125
137
  this.encode = function encode(str) {
138
+ /*jshint maxstatements:30 */
126
139
  var out = "", i = 0, len = str.length, c1, c2, c3;
127
140
  while (i < len) {
128
141
  c1 = str.charCodeAt(i++) & 0xff;
@@ -183,11 +196,12 @@
183
196
  /**
184
197
  * Fills a form with provided field values, and optionnaly submits it.
185
198
  *
186
- * @param HTMLElement|String form A form element, or a CSS3 selector to a form element
187
- * @param Object vals Field values
188
- * @return Object An object containing setting result for each field, including file uploads
199
+ * @param HTMLElement|String form A form element, or a CSS3 selector to a form element
200
+ * @param Object vals Field values
201
+ * @return Object An object containing setting result for each field, including file uploads
189
202
  */
190
203
  this.fill = function fill(form, vals) {
204
+ /*jshint maxcomplexity:8*/
191
205
  var out = {
192
206
  errors: [],
193
207
  fields: [],
@@ -214,7 +228,7 @@
214
228
  }
215
229
  var field = this.findAll('[name="' + name + '"]', form);
216
230
  var value = vals[name];
217
- if (!field) {
231
+ if (!field || field.length === 0) {
218
232
  out.errors.push('no field named "' + name + '" in form');
219
233
  continue;
220
234
  }
@@ -226,9 +240,10 @@
226
240
  name: name,
227
241
  path: err.path
228
242
  });
243
+ } else if(err.name === "FieldNotFound") {
244
+ out.errors.push('Form field named "' + name + '" was not found.');
229
245
  } else {
230
- this.log(err, "error");
231
- throw err;
246
+ out.errors.push(err.toString());
232
247
  }
233
248
  }
234
249
  }
@@ -243,11 +258,11 @@
243
258
  * @return NodeList|undefined
244
259
  */
245
260
  this.findAll = function findAll(selector, scope) {
246
- scope = scope || document;
261
+ scope = scope || this.options.scope;
247
262
  try {
248
263
  var pSelector = this.processSelector(selector);
249
264
  if (pSelector.type === 'xpath') {
250
- return this.getElementsByXPath(pSelector.path);
265
+ return this.getElementsByXPath(pSelector.path, scope);
251
266
  } else {
252
267
  return scope.querySelectorAll(pSelector.path);
253
268
  }
@@ -264,11 +279,11 @@
264
279
  * @return HTMLElement|undefined
265
280
  */
266
281
  this.findOne = function findOne(selector, scope) {
267
- scope = scope || document;
282
+ scope = scope || this.options.scope;
268
283
  try {
269
284
  var pSelector = this.processSelector(selector);
270
285
  if (pSelector.type === 'xpath') {
271
- return this.getElementByXPath(pSelector.path);
286
+ return this.getElementByXPath(pSelector.path, scope);
272
287
  } else {
273
288
  return scope.querySelector(pSelector.path);
274
289
  }
@@ -294,37 +309,14 @@
294
309
  * Retrieves string contents from a binary file behind an url. Silently
295
310
  * fails but log errors.
296
311
  *
297
- * @param String url
298
- * @param String method
299
- * @param Object data
300
- * @return string
312
+ * @param String url Url.
313
+ * @param String method HTTP method.
314
+ * @param Object data Request parameters.
315
+ * @return String
301
316
  */
302
317
  this.getBinary = function getBinary(url, method, data) {
303
318
  try {
304
- var xhr = new XMLHttpRequest(), dataString = "";
305
- if (typeof method !== "string" || ["GET", "POST"].indexOf(method.toUpperCase()) === -1) {
306
- method = "GET";
307
- } else {
308
- method = method.toUpperCase();
309
- }
310
- xhr.open(method, url, false);
311
- this.log("getBinary(): Using HTTP method: '" + method + "'", "debug");
312
- xhr.overrideMimeType("text/plain; charset=x-user-defined");
313
- if (method === "POST") {
314
- if (typeof data === "object") {
315
- var dataList = [];
316
- for (var k in data) {
317
- dataList.push(escape(k) + "=" + escape(data[k].toString()));
318
- }
319
- dataString = dataList.join('&');
320
- this.log("getBinary(): Using request data: '" + dataString + "'", "debug");
321
- } else if (typeof data === "string") {
322
- dataString = data;
323
- }
324
- xhr.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
325
- }
326
- xhr.send(method === "POST" ? dataString : null);
327
- return xhr.responseText;
319
+ return this.sendAJAX(url, method, data, false);
328
320
  } catch (e) {
329
321
  if (e.name === "NETWORK_ERR" && e.code === 101) {
330
322
  this.log("getBinary(): Unfortunately, casperjs cannot make cross domain ajax requests", "warning");
@@ -334,9 +326,25 @@
334
326
  }
335
327
  };
336
328
 
329
+ /**
330
+ * Retrieves total document height.
331
+ * http://james.padolsey.com/javascript/get-document-height-cross-browser/
332
+ *
333
+ * @return {Number}
334
+ */
335
+ this.getDocumentHeight = function getDocumentHeight() {
336
+ return Math.max(
337
+ Math.max(document.body.scrollHeight, document.documentElement.scrollHeight),
338
+ Math.max(document.body.offsetHeight, document.documentElement.offsetHeight),
339
+ Math.max(document.body.clientHeight, document.documentElement.clientHeight)
340
+ );
341
+ };
342
+
337
343
  /**
338
344
  * Retrieves bounding rect coordinates of the HTML element matching the
339
- * provided CSS3 selector
345
+ * provided CSS3 selector in the following form:
346
+ *
347
+ * {top: y, left: x, width: w, height:, h}
340
348
  *
341
349
  * @param String selector
342
350
  * @return Object or null
@@ -355,14 +363,72 @@
355
363
  }
356
364
  };
357
365
 
366
+ /**
367
+ * Retrieves the list of bounding rect coordinates for all the HTML elements matching the
368
+ * provided CSS3 selector, in the following form:
369
+ *
370
+ * [{top: y, left: x, width: w, height:, h},
371
+ * {top: y, left: x, width: w, height:, h},
372
+ * ...]
373
+ *
374
+ * @param String selector
375
+ * @return Array
376
+ */
377
+ this.getElementsBounds = function getElementsBounds(selector) {
378
+ var elements = this.findAll(selector);
379
+ var self = this;
380
+ try {
381
+ return Array.prototype.map.call(elements, function(element) {
382
+ var clipRect = element.getBoundingClientRect();
383
+ return {
384
+ top: clipRect.top,
385
+ left: clipRect.left,
386
+ width: clipRect.width,
387
+ height: clipRect.height
388
+ };
389
+ });
390
+ } catch (e) {
391
+ this.log("Unable to fetch bounds for elements matching " + selector, "warning");
392
+ }
393
+ };
394
+
395
+ /**
396
+ * Retrieves information about the node matching the provided selector.
397
+ *
398
+ * @param String|Object selector CSS3/XPath selector
399
+ * @return Object
400
+ */
401
+ this.getElementInfo = function getElementInfo(selector) {
402
+ var element = this.findOne(selector);
403
+ var bounds = this.getElementBounds(selector);
404
+ var attributes = {};
405
+ [].forEach.call(element.attributes, function(attr) {
406
+ attributes[attr.name.toLowerCase()] = attr.value;
407
+ });
408
+ return {
409
+ nodeName: element.nodeName.toLowerCase(),
410
+ attributes: attributes,
411
+ tag: element.outerHTML,
412
+ html: element.innerHTML,
413
+ text: element.innerText,
414
+ x: bounds.left,
415
+ y: bounds.top,
416
+ width: bounds.width,
417
+ height: bounds.height,
418
+ visible: this.visible(selector)
419
+ };
420
+ };
421
+
358
422
  /**
359
423
  * Retrieves a single DOM element matching a given XPath expression.
360
424
  *
361
- * @param String expression The XPath expression
425
+ * @param String expression The XPath expression
426
+ * @param HTMLElement|null scope Element to search child elements within
362
427
  * @return HTMLElement or null
363
428
  */
364
- this.getElementByXPath = function getElementByXPath(expression) {
365
- var a = document.evaluate(expression, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
429
+ this.getElementByXPath = function getElementByXPath(expression, scope) {
430
+ scope = scope || this.options.scope;
431
+ var a = document.evaluate(expression, scope, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
366
432
  if (a.snapshotLength > 0) {
367
433
  return a.snapshotItem(0);
368
434
  }
@@ -371,18 +437,87 @@
371
437
  /**
372
438
  * Retrieves all DOM elements matching a given XPath expression.
373
439
  *
374
- * @param String expression The XPath expression
440
+ * @param String expression The XPath expression
441
+ * @param HTMLElement|null scope Element to search child elements within
375
442
  * @return Array
376
443
  */
377
- this.getElementsByXPath = function getElementsByXPath(expression) {
444
+ this.getElementsByXPath = function getElementsByXPath(expression, scope) {
445
+ scope = scope || this.options.scope;
378
446
  var nodes = [];
379
- var a = document.evaluate(expression, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
447
+ var a = document.evaluate(expression, scope, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
380
448
  for (var i = 0; i < a.snapshotLength; i++) {
381
449
  nodes.push(a.snapshotItem(i));
382
450
  }
383
451
  return nodes;
384
452
  };
385
453
 
454
+ /**
455
+ * Retrieves the value of a form field.
456
+ *
457
+ * @param String inputName The for input name attr value
458
+ * @return Mixed
459
+ */
460
+ this.getFieldValue = function getFieldValue(inputName) {
461
+ function getSingleValue(input) {
462
+ try {
463
+ type = input.getAttribute('type').toLowerCase();
464
+ } catch (e) {
465
+ type = 'other';
466
+ }
467
+ if (['checkbox', 'radio'].indexOf(type) === -1) {
468
+ return input.value;
469
+ }
470
+ // single checkbox or… radio button (weird, I know)
471
+ if (input.hasAttribute('value')) {
472
+ return input.checked ? input.getAttribute('value') : undefined;
473
+ }
474
+ return input.checked;
475
+ }
476
+ function getMultipleValues(inputs) {
477
+ type = inputs[0].getAttribute('type').toLowerCase();
478
+ if (type === 'radio') {
479
+ var value;
480
+ [].forEach.call(inputs, function(radio) {
481
+ value = radio.checked ? radio.value : undefined;
482
+ });
483
+ return value;
484
+ } else if (type === 'checkbox') {
485
+ var values = [];
486
+ [].forEach.call(inputs, function(checkbox) {
487
+ if (checkbox.checked) {
488
+ values.push(checkbox.value);
489
+ }
490
+ });
491
+ return values;
492
+ }
493
+ }
494
+ var inputs = this.findAll('[name="' + inputName + '"]'), type;
495
+ switch (inputs.length) {
496
+ case 0: return null;
497
+ case 1: return getSingleValue(inputs[0]);
498
+ default: return getMultipleValues(inputs);
499
+ }
500
+ };
501
+
502
+ /**
503
+ * Retrieves a given form all of its field values.
504
+ *
505
+ * @param String selector A DOM CSS3/XPath selector
506
+ * @return Object
507
+ */
508
+ this.getFormValues = function getFormValues(selector) {
509
+ var form = this.findOne(selector);
510
+ var values = {};
511
+ var self = this;
512
+ [].forEach.call(form.elements, function(element) {
513
+ var name = element.getAttribute('name');
514
+ if (name) {
515
+ values[name] = self.getFieldValue(name);
516
+ }
517
+ });
518
+ return values;
519
+ };
520
+
386
521
  /**
387
522
  * Logs a message. Will format the message a way CasperJS will be able
388
523
  * to log phantomjs side.
@@ -407,11 +542,26 @@
407
542
  this.log("mouseEvent(): Couldn't find any element matching '" + selector + "' selector", "error");
408
543
  return false;
409
544
  }
410
- var evt = document.createEvent("MouseEvents");
411
- evt.initMouseEvent(type, true, true, window, 1, 1, 1, 1, 1, false, false, false, false, 0, elem);
412
- // dispatchEvent return value is false if at least one of the event
413
- // handlers which handled this event called preventDefault
414
- return elem.dispatchEvent(evt);
545
+ try {
546
+ var evt = document.createEvent("MouseEvents");
547
+ var center_x = 1, center_y = 1;
548
+ try {
549
+ var pos = elem.getBoundingClientRect();
550
+ center_x = Math.floor((pos.left + pos.right) / 2),
551
+ center_y = Math.floor((pos.top + pos.bottom) / 2);
552
+ } catch(e) {}
553
+ evt.initMouseEvent(type, true, true, window, 1, 1, 1, center_x, center_y, false, false, false, false, 0, elem);
554
+ // dispatchEvent return value is false if at least one of the event
555
+ // handlers which handled this event called preventDefault;
556
+ // so we cannot returns this results as it cannot accurately informs on the status
557
+ // of the operation
558
+ // let's assume the event has been sent ok it didn't raise any error
559
+ elem.dispatchEvent(evt);
560
+ return true;
561
+ } catch (e) {
562
+ this.log("Failed dispatching " + type + "mouse event on " + selector + ": " + e, "error");
563
+ return false;
564
+ }
415
565
  };
416
566
 
417
567
  /**
@@ -467,6 +617,39 @@
467
617
  }
468
618
  };
469
619
 
620
+ /**
621
+ * Performs an AJAX request.
622
+ *
623
+ * @param String url Url.
624
+ * @param String method HTTP method (default: GET).
625
+ * @param Object data Request parameters.
626
+ * @param Boolean async Asynchroneous request? (default: false)
627
+ * @return String Response text.
628
+ */
629
+ this.sendAJAX = function sendAJAX(url, method, data, async) {
630
+ var xhr = new XMLHttpRequest(),
631
+ dataString = "",
632
+ dataList = [];
633
+ method = method && method.toUpperCase() || "GET";
634
+ xhr.open(method, url, !!async);
635
+ this.log("sendAJAX(): Using HTTP method: '" + method + "'", "debug");
636
+ xhr.overrideMimeType("text/plain; charset=x-user-defined");
637
+ if (method === "POST") {
638
+ if (typeof data === "object") {
639
+ for (var k in data) {
640
+ dataList.push(encodeURIComponent(k) + "=" + encodeURIComponent(data[k].toString()));
641
+ }
642
+ dataString = dataList.join('&');
643
+ this.log("sendAJAX(): Using request data: '" + dataString + "'", "debug");
644
+ } else if (typeof data === "string") {
645
+ dataString = data;
646
+ }
647
+ xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
648
+ }
649
+ xhr.send(method === "POST" ? dataString : null);
650
+ return xhr.responseText;
651
+ };
652
+
470
653
  /**
471
654
  * Sets a field (or a set of fields) value. Fails silently, but log
472
655
  * error messages.
@@ -475,14 +658,17 @@
475
658
  * @param mixed value The field value to set
476
659
  */
477
660
  this.setField = function setField(field, value) {
661
+ /*jshint maxcomplexity:99 */
478
662
  var logValue, fields, out;
479
663
  value = logValue = (value || "");
480
664
  if (field instanceof NodeList) {
481
665
  fields = field;
482
666
  field = fields[0];
483
667
  }
484
- if (!field instanceof HTMLElement) {
485
- this.log("Invalid field type; only HTMLElement and NodeList are supported", "error");
668
+ if (!(field instanceof HTMLElement)) {
669
+ var error = new Error('Invalid field type; only HTMLElement and NodeList are supported');
670
+ error.name = 'FieldNotFound';
671
+ throw error;
486
672
  }
487
673
  if (this.options && this.options.safeLogs && field.getAttribute('type') === "password") {
488
674
  // obfuscate password value
@@ -542,7 +728,7 @@
542
728
  e.checked = (e.value === value);
543
729
  });
544
730
  } else {
545
- out = 'Urovided radio elements are empty';
731
+ out = 'Provided radio elements are empty';
546
732
  }
547
733
  break;
548
734
  default:
@@ -64,7 +64,8 @@ var Colorizer = function Colorizer() {
64
64
  'WARNING': { fg: 'red', bold: true },
65
65
  'GREEN_BAR': { fg: 'white', bg: 'green', bold: true },
66
66
  'RED_BAR': { fg: 'white', bg: 'red', bold: true },
67
- 'INFO_BAR': { bg: 'cyan', fg: 'white', bold: true }
67
+ 'INFO_BAR': { bg: 'cyan', fg: 'white', bold: true },
68
+ 'WARN_BAR': { bg: 'yellow', fg: 'white', bold: true }
68
69
  };
69
70
 
70
71
  /**