Merge lp:~wgrant/launchpad/webhook-deliveries-ui into lp:launchpad

Proposed by William Grant
Status: Merged
Merged at revision: 17721
Proposed branch: lp:~wgrant/launchpad/webhook-deliveries-ui
Merge into: lp:launchpad
Diff against target: 1115 lines (+784/-51)
16 files modified
Makefile (+2/-1)
lib/canonical/launchpad/icing/style.css (+7/-0)
lib/lp/app/javascript/date.js (+14/-4)
lib/lp/app/javascript/tests/test_date.js (+16/-2)
lib/lp/bugs/browser/buglisting.py (+6/-17)
lib/lp/registry/browser/pillar.py (+3/-22)
lib/lp/services/webapp/batching.py (+28/-0)
lib/lp/services/webhooks/browser.py (+19/-1)
lib/lp/services/webhooks/interfaces.py (+5/-0)
lib/lp/services/webhooks/javascript/deliveries.js (+297/-0)
lib/lp/services/webhooks/javascript/tests/test_deliveries.html (+73/-0)
lib/lp/services/webhooks/javascript/tests/test_deliveries.js (+214/-0)
lib/lp/services/webhooks/model.py (+6/-1)
lib/lp/services/webhooks/templates/webhook-index.pt (+64/-0)
lib/lp/services/webhooks/tests/test_webservice.py (+4/-3)
lib/lp/services/webhooks/tests/test_yuitests.py (+26/-0)
To merge this branch: bzr merge lp:~wgrant/launchpad/webhook-deliveries-ui
Reviewer Review Type Date Requested Status
Colin Watson (community) Approve
Review via email: mp+270516@code.launchpad.net

Commit message

Add delivery management to Webhook:+index.

Description of the change

This branch adds basic JavaScript-only delivery management to Webhook:+index. It uses the same listing_navigator as +bugs and +sharing to pull batches from LP, but renders the content using a custom widget with the ability to get details and retry failed deliveries.

The expanded view is rather sparse in this iteration. It will later contain expandable views of the payload and request and response headers, and update automatically when pending deliveries occur.

The main JS logic is reasonably tested, but some edge cases and the retry action need significant refactoring before they can be.

To post a comment you must log in.
Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
William Grant (wgrant) :
Revision history for this message
Colin Watson (cjwatson) :

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'Makefile'
2--- Makefile 2015-08-21 13:09:09 +0000
3+++ Makefile 2015-09-09 13:04:04 +0000
4@@ -176,7 +176,8 @@
5
6 $(LP_JS_BUILD): | $(JS_BUILD_DIR)
7 -mkdir $@
8- for jsdir in lib/lp/*/javascript; do \
9+ -mkdir $@/services
10+ for jsdir in lib/lp/*/javascript lib/lp/services/*/javascript; do \
11 app=$$(echo $$jsdir | sed -e 's,lib/lp/\(.*\)/javascript,\1,'); \
12 cp -a $$jsdir $@/$$app; \
13 done
14
15=== modified file 'lib/canonical/launchpad/icing/style.css'
16--- lib/canonical/launchpad/icing/style.css 2015-08-13 07:05:21 +0000
17+++ lib/canonical/launchpad/icing/style.css 2015-09-09 13:04:04 +0000
18@@ -871,6 +871,13 @@
19 }
20
21
22+/* === Webhooks === */
23+
24+table.listing tr.webhook-delivery:hover {
25+ text-decoration: underline;
26+ cursor: pointer;
27+}
28+
29
30 /* ====== Content area styles ====== */
31
32
33=== modified file 'lib/lp/app/javascript/date.js'
34--- lib/lp/app/javascript/date.js 2014-07-08 15:25:12 +0000
35+++ lib/lp/app/javascript/date.js 2015-09-09 13:04:04 +0000
36@@ -8,7 +8,9 @@
37 namespace.parse_date = function(str) {
38 // Parse an ISO-8601 date
39 var re = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|\+00:00)$/;
40- var bits = re.exec(str).slice(1, 8).map(Number);
41+ // Milliseconds may be missing and are boring, so only take year to
42+ // seconds.
43+ var bits = re.exec(str).slice(1, 7).map(Number);
44 // Adjusting for the fact that Date.UTC uses 0-11 for months
45 bits[1] -= 1;
46 return new Date(Date.UTC.apply(null, bits));
47@@ -19,11 +21,19 @@
48 // day ago.
49 var now = (new Date).valueOf();
50 var timedelta = now - date;
51+ var unit = "";
52+ var prefix = "";
53+ var suffix = "";
54+ if (timedelta >= 0) {
55+ suffix = " ago";
56+ } else {
57+ prefix = "in ";
58+ timedelta = -timedelta;
59+ }
60 var days = timedelta / 86400000;
61 var hours = timedelta / 3600000;
62 var minutes = timedelta / 60000;
63 var amount = 0;
64- var unit = "";
65 if (days > 1) {
66 return 'on ' + Y.Date.format(
67 new Date(date), {format: '%Y-%m-%d'});
68@@ -35,12 +45,12 @@
69 amount = minutes;
70 unit = "minute";
71 } else {
72- return "a moment ago";
73+ return prefix + "a moment" + suffix;
74 }
75 if (Math.floor(amount) > 1) {
76 unit = unit + 's';
77 }
78- return Math.floor(amount) + ' ' + unit + ' ago';
79+ return prefix + Math.floor(amount) + ' ' + unit + suffix;
80 }
81 };
82 }, "0.1", {'requires': ['datatype-date']});
83
84=== modified file 'lib/lp/app/javascript/tests/test_date.js'
85--- lib/lp/app/javascript/tests/test_date.js 2014-07-08 15:25:12 +0000
86+++ lib/lp/app/javascript/tests/test_date.js 2015-09-09 13:04:04 +0000
87@@ -12,24 +12,38 @@
88 'a moment ago',
89 Y.lp.app.date.approximatedate(new Date(now - 150)));
90 },
91-
92+
93 test_return_minute_ago: function () {
94 Y.Assert.areEqual(
95 '1 minute ago',
96 Y.lp.app.date.approximatedate(new Date(now - 65000)));
97 },
98-
99+
100 test_return_hours_ago: function () {
101 Y.Assert.areEqual(
102 '3 hours ago',
103 Y.lp.app.date.approximatedate(new Date(now - 12600000)));
104 },
105+
106 test_return_days_ago: function () {
107 Y.Assert.areEqual(
108 'on 2012-08-12', Y.lp.app.date.approximatedate(
109 Y.lp.app.date.parse_date(
110 '2012-08-12T10:00:00.00001+00:00')));
111 },
112+
113+ test_return_in_moment: function () {
114+ Y.Assert.areEqual(
115+ 'in a moment',
116+ Y.lp.app.date.approximatedate(new Date(now + 15000)));
117+ },
118+
119+ test_return_in_hours: function () {
120+ Y.Assert.areEqual(
121+ 'in 3 hours',
122+ Y.lp.app.date.approximatedate(new Date(now + 12600000)));
123+ }
124+
125 }));
126
127 }, '0.1', {
128
129=== modified file 'lib/lp/bugs/browser/buglisting.py'
130--- lib/lp/bugs/browser/buglisting.py 2015-07-09 20:06:17 +0000
131+++ lib/lp/bugs/browser/buglisting.py 2015-09-09 13:04:04 +0000
132@@ -140,7 +140,10 @@
133 NavigationMenu,
134 )
135 from lp.services.webapp.authorization import check_permission
136-from lp.services.webapp.batching import TableBatchNavigator
137+from lp.services.webapp.batching import (
138+ get_batch_properties_for_json_cache,
139+ TableBatchNavigator,
140+ )
141 from lp.services.webapp.interfaces import ILaunchBag
142
143
144@@ -1077,6 +1080,8 @@
145 cache.objects['view_name'] = view_names.pop()
146 batch_navigator = self.search()
147 cache.objects['mustache_model'] = batch_navigator.model
148+ cache.objects.update(
149+ get_batch_properties_for_json_cache(self, batch_navigator))
150 cache.objects['field_visibility'] = (
151 batch_navigator.field_visibility)
152 cache.objects['field_visibility_defaults'] = (
153@@ -1084,24 +1089,8 @@
154 cache.objects['cbl_cookie_name'] = (
155 batch_navigator.getCookieName())
156
157- def _getBatchInfo(batch):
158- if batch is None:
159- return None
160- return {'memo': batch.range_memo,
161- 'start': batch.startNumber() - 1}
162-
163- next_batch = batch_navigator.batch.nextBatch()
164- cache.objects['next'] = _getBatchInfo(next_batch)
165- prev_batch = batch_navigator.batch.prevBatch()
166- cache.objects['prev'] = _getBatchInfo(prev_batch)
167- cache.objects['total'] = batch_navigator.batch.total()
168 cache.objects['order_by'] = ','.join(
169 get_sortorder_from_request(self.request))
170- cache.objects['forwards'] = (
171- batch_navigator.batch.range_forwards)
172- last_batch = batch_navigator.batch.lastBatch()
173- cache.objects['last_start'] = last_batch.startNumber() - 1
174- cache.objects.update(_getBatchInfo(batch_navigator.batch))
175 cache.objects['sort_keys'] = SORT_KEYS
176
177 @property
178
179=== modified file 'lib/lp/registry/browser/pillar.py'
180--- lib/lp/registry/browser/pillar.py 2015-07-08 16:05:11 +0000
181+++ lib/lp/registry/browser/pillar.py 2015-09-09 13:04:04 +0000
182@@ -33,7 +33,6 @@
183 )
184 from zope.traversing.browser.absoluteurl import absoluteURL
185
186-from lp.app.browser.launchpad import iter_view_registrations
187 from lp.app.browser.lazrjs import vocabulary_to_choice_edit_items
188 from lp.app.browser.tales import MenuAPI
189 from lp.app.browser.vocabulary import vocabulary_filters
190@@ -61,6 +60,7 @@
191 from lp.services.webapp.authorization import check_permission
192 from lp.services.webapp.batching import (
193 BatchNavigator,
194+ get_batch_properties_for_json_cache,
195 StormRangeFactory,
196 )
197 from lp.services.webapp.breadcrumb import (
198@@ -375,14 +375,11 @@
199 self.specification_sharing_policies)
200 cache.objects['has_edit_permission'] = check_permission(
201 "launchpad.Edit", self.context)
202- view_names = set(reg.name for reg in
203- iter_view_registrations(self.__class__))
204- if len(view_names) != 1:
205- raise AssertionError("Ambiguous view name.")
206- cache.objects['view_name'] = view_names.pop()
207 batch_navigator = self.grantees()
208 cache.objects['grantee_data'] = (
209 self._getSharingService().jsonGranteeData(batch_navigator.batch))
210+ cache.objects.update(
211+ get_batch_properties_for_json_cache(self, batch_navigator))
212
213 grant_counts = (
214 self._getSharingService().getAccessPolicyGrantCounts(self.context))
215@@ -390,22 +387,6 @@
216 count_info[0].title for count_info in grant_counts
217 if count_info[1] == 0]
218
219- def _getBatchInfo(batch):
220- if batch is None:
221- return None
222- return {'memo': batch.range_memo,
223- 'start': batch.startNumber() - 1}
224-
225- next_batch = batch_navigator.batch.nextBatch()
226- cache.objects['next'] = _getBatchInfo(next_batch)
227- prev_batch = batch_navigator.batch.prevBatch()
228- cache.objects['prev'] = _getBatchInfo(prev_batch)
229- cache.objects['total'] = batch_navigator.batch.total()
230- cache.objects['forwards'] = batch_navigator.batch.range_forwards
231- last_batch = batch_navigator.batch.lastBatch()
232- cache.objects['last_start'] = last_batch.startNumber() - 1
233- cache.objects.update(_getBatchInfo(batch_navigator.batch))
234-
235
236 class PillarPersonSharingView(LaunchpadView):
237
238
239=== modified file 'lib/lp/services/webapp/batching.py'
240--- lib/lp/services/webapp/batching.py 2015-07-09 12:18:51 +0000
241+++ lib/lp/services/webapp/batching.py 2015-09-09 13:04:04 +0000
242@@ -34,6 +34,7 @@
243 removeSecurityProxy,
244 )
245
246+from lp.app.browser.launchpad import iter_view_registrations
247 from lp.services.config import config
248 from lp.services.database.decoratedresultset import DecoratedResultSet
249 from lp.services.database.interfaces import ISlaveStore
250@@ -49,6 +50,33 @@
251 from lp.services.webapp.publisher import LaunchpadView
252
253
254+def get_batch_properties_for_json_cache(view, batchnav):
255+ """Get values to insert into `IJSONRequestCache` for JS batchnavs."""
256+ properties = {}
257+ view_names = set(
258+ reg.name for reg in iter_view_registrations(view.__class__))
259+ if len(view_names) != 1:
260+ raise AssertionError("Ambiguous view name.")
261+ properties['view_name'] = view_names.pop()
262+
263+ def _getBatchInfo(batch):
264+ if batch is None:
265+ return None
266+ return {'memo': batch.range_memo,
267+ 'start': batch.startNumber() - 1}
268+
269+ next_batch = batchnav.batch.nextBatch()
270+ properties['next'] = _getBatchInfo(next_batch)
271+ prev_batch = batchnav.batch.prevBatch()
272+ properties['prev'] = _getBatchInfo(prev_batch)
273+ properties['total'] = batchnav.batch.total()
274+ properties['forwards'] = batchnav.batch.range_forwards
275+ last_batch = batchnav.batch.lastBatch()
276+ properties['last_start'] = last_batch.startNumber() - 1
277+ properties.update(_getBatchInfo(batchnav.batch))
278+ return properties
279+
280+
281 @adapter(IResultSet)
282 @implementer(IFiniteSequence)
283 class FiniteSequenceAdapter:
284
285=== modified file 'lib/lp/services/webhooks/browser.py'
286--- lib/lp/services/webhooks/browser.py 2015-08-10 06:39:16 +0000
287+++ lib/lp/services/webhooks/browser.py 2015-09-09 13:04:04 +0000
288@@ -11,6 +11,7 @@
289 ]
290
291 from lazr.restful.interface import use_template
292+from lazr.restful.interfaces import IJSONRequestCache
293 from zope.component import getUtility
294 from zope.interface import Interface
295
296@@ -28,7 +29,11 @@
297 Navigation,
298 stepthrough,
299 )
300-from lp.services.webapp.batching import BatchNavigator
301+from lp.services.webapp.batching import (
302+ BatchNavigator,
303+ get_batch_properties_for_json_cache,
304+ StormRangeFactory,
305+ )
306 from lp.services.webapp.breadcrumb import Breadcrumb
307 from lp.services.webhooks.interfaces import (
308 IWebhook,
309@@ -143,6 +148,19 @@
310 schema = WebhookEditSchema
311 custom_widget('event_types', LabeledMultiCheckBoxWidget)
312
313+ def initialize(self):
314+ super(WebhookView, self).initialize()
315+ cache = IJSONRequestCache(self.request)
316+ cache.objects['deliveries'] = list(self.deliveries.batch)
317+ cache.objects.update(
318+ get_batch_properties_for_json_cache(self, self.deliveries))
319+
320+ @cachedproperty
321+ def deliveries(self):
322+ return BatchNavigator(
323+ self.context.deliveries, self.request, hide_counts=True,
324+ range_factory=StormRangeFactory(self.context.deliveries))
325+
326 @property
327 def next_url(self):
328 # The edit form is the default view, so the URL doesn't need the
329
330=== modified file 'lib/lp/services/webhooks/interfaces.py'
331--- lib/lp/services/webhooks/interfaces.py 2015-08-10 08:09:09 +0000
332+++ lib/lp/services/webhooks/interfaces.py 2015-09-09 13:04:04 +0000
333@@ -258,6 +258,11 @@
334 date_created = exported(Datetime(
335 title=_("Date created"), required=True, readonly=True))
336
337+ date_scheduled = exported(Datetime(
338+ title=_("Date scheduled"),
339+ description=_("Timestamp of the next delivery attempt."),
340+ required=False, readonly=True))
341+
342 date_first_sent = exported(Datetime(
343 title=_("Date first sent"),
344 description=_("Timestamp of the first delivery attempt."),
345
346=== added directory 'lib/lp/services/webhooks/javascript'
347=== added file 'lib/lp/services/webhooks/javascript/deliveries.js'
348--- lib/lp/services/webhooks/javascript/deliveries.js 1970-01-01 00:00:00 +0000
349+++ lib/lp/services/webhooks/javascript/deliveries.js 2015-09-09 13:04:04 +0000
350@@ -0,0 +1,297 @@
351+/* Copyright 2015 Canonical Ltd. This software is licensed under the
352+ * GNU Affero General Public License version 3 (see the file LICENSE).
353+ *
354+ * Webhook delivery widgets.
355+ *
356+ * @module lp.services.webhooks.deliveries
357+ */
358+
359+YUI.add("lp.services.webhooks.deliveries", function(Y) {
360+
361+var namespace = Y.namespace("lp.services.webhooks.deliveries");
362+
363+function WebhookDeliveriesListingNavigator(config) {
364+ WebhookDeliveriesListingNavigator.superclass.constructor.apply(
365+ this, arguments);
366+}
367+
368+WebhookDeliveriesListingNavigator.NAME =
369+ 'webhook-deliveries-listing-navigator';
370+
371+WebhookDeliveriesListingNavigator.UPDATE_CONTENT = 'updateContent';
372+
373+Y.extend(WebhookDeliveriesListingNavigator,
374+ Y.lp.app.listing_navigator.ListingNavigator, {
375+
376+ initializer: function(config) {
377+ this.publish(
378+ namespace.WebhookDeliveriesListingNavigator.UPDATE_CONTENT);
379+ },
380+
381+ render_content: function() {
382+ this.fire(
383+ namespace.WebhookDeliveriesListingNavigator.UPDATE_CONTENT,
384+ this.get_current_batch().deliveries);
385+ },
386+
387+ _batch_size: function(batch) {
388+ return batch.deliveries.length;
389+ }
390+
391+});
392+
393+namespace.WebhookDeliveriesListingNavigator =
394+ WebhookDeliveriesListingNavigator;
395+
396+function WebhookDeliveries(config) {
397+ WebhookDeliveries.superclass.constructor.apply(this, arguments);
398+}
399+
400+WebhookDeliveries.NAME = "webhook-deliveries";
401+
402+WebhookDeliveries.ATTRS = {
403+
404+ deliveries: {
405+ value: [],
406+ setter: function(deliveries) {
407+ var self = this;
408+ // Objects in LP.cache.deliveries are not wrapped on page
409+ // load, but the objects we get from batch navigation are.
410+ // Ensure we're always dealing with wrapped ones.
411+ return Y.Array.map(deliveries, function(delivery) {
412+ if (delivery.get === undefined) {
413+ return self.lp_client.wrap_resource(null, delivery);
414+ } else {
415+ return delivery;
416+ }
417+ });
418+ }
419+ }
420+
421+};
422+
423+Y.extend(WebhookDeliveries, Y.Widget, {
424+
425+ initializer: function(config) {
426+ this.lp_client = new Y.lp.client.Launchpad();
427+ this.delivery_info = {};
428+
429+ var self = this;
430+ this.after("deliveriesChange", function(e) {
431+ // Populate the delivery_info map for any deliveries we
432+ // haven't seen before, and refresh any that we have.
433+ Y.Array.each(self.get("deliveries"), function(res) {
434+ if (!Y.Object.owns(self.delivery_info, res.get("self_link"))) {
435+ self.delivery_info[res.get("self_link")] = {
436+ resource: res, expanded: false,
437+ requesting_retry: false};
438+ } else {
439+ self.delivery_info[res.get("self_link")].resource = res;
440+ }
441+ });
442+ // Update the list of delivery URLs to display.
443+ self.deliveries_displayed = Y.Array.map(
444+ self.get("deliveries"),
445+ function(res) {return res.get("self_link");});
446+ if (self.get("rendered")) {
447+ self.syncUI();
448+ }
449+ });
450+ },
451+
452+ bindUI: function() {
453+ var table = this.get("contentBox").one("#webhook-deliveries-table");
454+ table.delegate("click", this._toggle_detail.bind(this), "tbody > tr");
455+ table.delegate(
456+ "click", this._trigger_retry.bind(this), ".delivery-retry");
457+ },
458+
459+ syncUI: function() {
460+ var table = this.get("contentBox").one("#webhook-deliveries-table");
461+ var new_tbody = Y.Node.create("<tbody></tbody>");
462+ var self = this;
463+ Y.Array.each(this.deliveries_displayed, function(delivery_url) {
464+ var delivery = self.delivery_info[delivery_url];
465+ var resource = delivery.resource;
466+ var delivery_node = self._render_delivery(delivery);
467+ new_tbody.append(delivery_node);
468+ if (delivery.expanded) {
469+ var detail_node = self._render_detail(delivery, delivery_node);
470+ delivery_node.setData('delivery-detail-tr', detail_node);
471+ new_tbody.append(detail_node);
472+ var date_scheduled =
473+ resource.get("date_scheduled") !== null
474+ ? Y.lp.app.date.parse_date(resource.get("date_scheduled"))
475+ : null;
476+ var retrying_now =
477+ delivery.requesting_retry || (
478+ resource.get("pending") && (
479+ date_scheduled === null
480+ || date_scheduled < new Date()));
481+ if (retrying_now) {
482+ // Retrying already, or we're currently requesting one.
483+ detail_node.one(".delivery-retry-notice").addClass("hidden");
484+ detail_node.one(".delivery-retry").addClass("hidden");
485+ detail_node.one(".delivery-delivering-notice")
486+ .removeClass("hidden");
487+ } else if (resource.get("pending")) {
488+ // Retry scheduled for the future.
489+ var retrying_text =
490+ Y.lp.app.date.approximatedate(date_scheduled);
491+ detail_node.one(".delivery-retry-notice").set(
492+ "text", "Retrying " + retrying_text + ".");
493+ detail_node.one(".delivery-retry").set("text", "Retry now");
494+ detail_node.one(".delivery-retry-notice").removeClass("hidden");
495+ detail_node.one(".delivery-retry").removeClass("hidden");
496+ detail_node.one(".delivery-delivering-notice").addClass("hidden");
497+ } else {
498+ var retry_text = resource.successful ? "Redeliver" : "Retry";
499+ detail_node.one(".delivery-retry").set("text", retry_text);
500+ detail_node.one(".delivery-retry-notice").addClass("hidden");
501+ detail_node.one(".delivery-delivering-notice").addClass("hidden");
502+ detail_node.one(".delivery-retry").removeClass("hidden");
503+ }
504+ }
505+ });
506+ table.one("tbody").replace(new_tbody);
507+ },
508+
509+ _pick_sprite: function(delivery) {
510+ if (delivery.resource.get("pending")
511+ && delivery.resource.get("successful") === null) {
512+ return "milestone";
513+ } else if (delivery.resource.get("successful")) {
514+ return "yes";
515+ } else if (delivery.resource.get("pending")
516+ || delivery.requesting_retry) {
517+ return "warning-icon";
518+ } else {
519+ return "no";
520+ }
521+ },
522+
523+ _render_delivery: function(delivery) {
524+ var row_template = [
525+ '<tr class="webhook-delivery">',
526+ '<td><span class="sprite {{sprite}}" /></td>',
527+ '<td>{{date}}</td>',
528+ '<td>{{event_type}}</td>',
529+ '<td>{{status}}</td>',
530+ '</tr>'].join(' ');
531+ context = {
532+ sprite: this._pick_sprite(delivery),
533+ date: Y.lp.app.date.approximatedate(Y.lp.app.date.parse_date(
534+ delivery.resource.get("date_created"))),
535+ event_type: delivery.resource.get("event_type"),
536+ status: delivery.resource.get("error_message")};
537+ var new_row = Y.Node.create(Y.lp.mustache.to_html(
538+ row_template, context));
539+ new_row.setData("delivery-url", delivery.resource.get("self_link"));
540+ return new_row;
541+ },
542+
543+ _format_date: function(iso8601) {
544+ // The ISO8601 timestamp is in UTC, but JS converts it in local
545+ // time. LP generally gives timestamps in the user's profile
546+ // timezone, but the browser timezone may differ, so let's use
547+ // UTC and be explicit about it.
548+ // Using the browser's local timezone, mangle it to UTC
549+ // masquerading as local time and format it nicely.
550+ if (iso8601 === null) {
551+ return "unknown";
552+ }
553+ var local_date = Y.lp.app.date.parse_date(iso8601);
554+ return Y.Date.format(
555+ new Date(
556+ local_date.getTime()
557+ + local_date.getTimezoneOffset() * 60 * 1000),
558+ {format: "%Y-%m-%d %H:%M:%S UTC"});
559+ },
560+
561+ _render_detail: function(delivery, delivery_node) {
562+ var detail_node = Y.Node.create([
563+ '<tr class="webhook-delivery-detail">',
564+ '<td></td>',
565+ '<td colspan="3" class="webhook-delivery-detail">',
566+ '<span class="text"></span>',
567+ '<div>',
568+ '<span class="delivery-retry-notice hidden"></span> ',
569+ '<a class="js-action delivery-retry">Retry</a>',
570+ '<span class="delivery-delivering-notice hidden">',
571+ ' <img src="/@@/spinner" /> Delivering...',
572+ '</span>',
573+ '</div>',
574+ '</td>',
575+ '</tr>'].join(''));
576+ detail_node.setData('delivery-tr', delivery_node);
577+ var date_sent = this._format_date(delivery.resource.get("date_sent"));
578+ var status_text = null;
579+ if (delivery.resource.get("successful") === true) {
580+ status_text = "Delivered at " + date_sent + ".";
581+ } else if (delivery.resource.get("successful") === false) {
582+ if (delivery.resource.get("date_first_sent")) {
583+ var date_first_sent = this._format_date(
584+ delivery.resource.get("date_first_sent"));
585+ status_text =
586+ "Tried since " + date_first_sent + ", last failed at "
587+ + date_sent + ".";
588+ } else {
589+ status_text = "Failed at " + date_sent + ".";
590+ }
591+ }
592+ detail_node.one('span.text').set('text', status_text);
593+ return detail_node;
594+ },
595+
596+ _toggle_detail: function(e) {
597+ var delivery_url = e.currentTarget.getData('delivery-url');
598+ if (delivery_url === undefined) {
599+ // Not actually a delivery row.
600+ return;
601+ }
602+ var delivery = this.delivery_info[delivery_url];
603+ delivery.expanded = !delivery.expanded;
604+ this.syncUI();
605+ },
606+
607+ _trigger_retry: function(e) {
608+ var detail_node = e.currentTarget.ancestor('tr', false);
609+ var delivery_url =
610+ detail_node.getData('delivery-tr').getData('delivery-url');
611+ var delivery = this.delivery_info[delivery_url];
612+ if (delivery.requesting_retry) {
613+ // Already in progress.
614+ return;
615+ }
616+ delivery.requesting_retry = true;
617+ this.syncUI();
618+ // XXX wgrant 2015-09-09: This should fire an event to an
619+ // external model object, which forwards the retry to the server,
620+ // refreshes our copy, then fires an event back to update the
621+ // widget.
622+ var self = this;
623+ var config = {
624+ on: {
625+ success: function() {
626+ // TODO: Refresh the object and unset requesting_retry.
627+ // For now this will leave "Delivering" spinner in
628+ // place, which is the most likely result anyway.
629+ self.syncUI();
630+ },
631+ failure: function() {
632+ // TODO: Display error popup.
633+ delivery.requesting_retry = false;
634+ self.syncUI();
635+ }
636+ }
637+ };
638+ this.lp_client.named_post(delivery_url, 'retry', config);
639+ }
640+
641+});
642+
643+namespace.WebhookDeliveries = WebhookDeliveries;
644+
645+}, "0.1", {"requires": ["event", "node", "widget", "lp.app.date",
646+ "lp.app.listing_navigator", "lp.client",
647+ "lp.mustache"]});
648
649=== added directory 'lib/lp/services/webhooks/javascript/tests'
650=== added file 'lib/lp/services/webhooks/javascript/tests/test_deliveries.html'
651--- lib/lp/services/webhooks/javascript/tests/test_deliveries.html 1970-01-01 00:00:00 +0000
652+++ lib/lp/services/webhooks/javascript/tests/test_deliveries.html 2015-09-09 13:04:04 +0000
653@@ -0,0 +1,73 @@
654+<!DOCTYPE html>
655+<!--
656+Copyright 2015 Canonical Ltd. This software is licensed under the
657+GNU Affero General Public License version 3 (see the file LICENSE).
658+-->
659+
660+<html>
661+ <head>
662+ <title>Webhook deliveries widget tests</title>
663+
664+ <!-- YUI and test setup -->
665+ <script type="text/javascript"
666+ src="../../../../../../build/js/yui/yui/yui.js">
667+ </script>
668+ <link rel="stylesheet"
669+ href="../../../../../../build/js/yui/console/assets/console-core.css" />
670+ <link rel="stylesheet"
671+ href="../../../../../../build/js/yui/test-console/assets/skins/sam/test-console.css" />
672+ <link rel="stylesheet"
673+ href="../../../../../../build/js/yui/test/assets/skins/sam/test.css" />
674+
675+ <script type="text/javascript"
676+ src="../../../../../../build/js/lp/app/testing/testrunner.js"></script>
677+
678+ <link rel="stylesheet" href="../../../../app/javascript/testing/test.css" />
679+
680+ <!-- Dependencies -->
681+ <script type="text/javascript"
682+ src="../../../../../../build/js/lp/app/testing/mockio.js"></script>
683+ <script type="text/javascript"
684+ src="../../../../../../build/js/lp/app/client.js"></script>
685+ <script type="text/javascript"
686+ src="../../../../../../build/js/lp/app/date.js"></script>
687+ <script type="text/javascript"
688+ src="../../../../../../build/js/lp/app/errors.js"></script>
689+ <script type="text/javascript"
690+ src="../../../../../../build/js/lp/app/lp.js"></script>
691+ <script type="text/javascript"
692+ src="../../../../../../build/js/lp/app/mustache.js"></script>
693+ <script type="text/javascript"
694+ src="../../../../../../build/js/lp/app/listing_navigator.js"></script>
695+
696+ <!-- The module under test. -->
697+ <script type="text/javascript" src="../deliveries.js"></script>
698+
699+ <!-- The test suite -->
700+ <script type="text/javascript" src="test_deliveries.js"></script>
701+
702+ <script id="fixture-template" type="text/x-template">
703+ <div id="webhook-deliveries">
704+ <table id="webhook-deliveries-table" class="listing">
705+ <colgroup>
706+ <col style="width: 18px" />
707+ </colgroup>
708+ <tbody>
709+ <tr id='webhook-deliveries-table-loading'>
710+ <td colspan="3" style="padding-left: 0.25em">
711+ <img class="spinner" src="/@@/spinner" alt="Loading..." />
712+ Loading...
713+ </td>
714+ </tr>
715+ </tbody>
716+ </table>
717+ </div>
718+ </script>
719+ </head>
720+ <body class="yui3-skin-sam">
721+ <ul id="suites">
722+ <li>lp.services.webhooks.deliveries.test</li>
723+ </ul>
724+ <div id="fixture"></div>
725+ </body>
726+</html>
727
728=== added file 'lib/lp/services/webhooks/javascript/tests/test_deliveries.js'
729--- lib/lp/services/webhooks/javascript/tests/test_deliveries.js 1970-01-01 00:00:00 +0000
730+++ lib/lp/services/webhooks/javascript/tests/test_deliveries.js 2015-09-09 13:04:04 +0000
731@@ -0,0 +1,214 @@
732+/* Copyright 2015 Canonical Ltd. This software is licensed under the
733+ * GNU Affero General Public License version 3 (see the file LICENSE). */
734+
735+YUI.add('lp.services.webhooks.deliveries.test', function (Y) {
736+
737+ var tests = Y.namespace('lp.services.webhooks.deliveries.test');
738+ tests.suite = new Y.Test.Suite(
739+ 'lp.services.webhooks.deliveries Tests');
740+
741+ var DELIVERY_PENDING = {
742+ "event_type": "ping", "successful": null, "error_message": null,
743+ "date_sent": null, "self_link": "http://example.com/delivery/1",
744+ "date_created": "2014-09-08T01:19:16+00:00", "date_scheduled": null,
745+ "pending": true, "resource_type_link": "#webhook_delivery"};
746+
747+ var DELIVERY_SUCCESSFUL = {
748+ "event_type": "ping", "successful": true,
749+ "error_message": null,
750+ "date_sent": "2014-09-08T01:19:16+00:00",
751+ "self_link": "http://example.com/delivery/2",
752+ "date_created": "2014-09-08T01:19:16+00:00",
753+ "date_scheduled": null, "pending": false,
754+ "resource_type_link": "#webhook_delivery"};
755+
756+ var DELIVERY_SUCCESSFUL_RETRY_NOW = {
757+ "event_type": "ping", "successful": true,
758+ "error_message": null,
759+ "date_sent": "2014-09-08T01:19:16+00:00",
760+ "self_link": "http://example.com/delivery/2",
761+ "date_created": "2014-09-08T01:19:16+00:00",
762+ "date_scheduled": "2014-09-08T01:19:16+00:00",
763+ "pending": true, "resource_type_link": "#webhook_delivery"};
764+
765+ var DELIVERY_FAILED = {
766+ "event_type": "ping", "successful": false,
767+ "error_message": "Bad HTTP response: 404",
768+ "date_sent": "2014-09-08T01:19:16+00:00",
769+ "self_link": "http://example.com/delivery/2",
770+ "date_created": "2014-09-08T01:19:16+00:00",
771+ "date_scheduled": null, "pending": false,
772+ "resource_type_link": "#webhook_delivery"};
773+
774+ var DELIVERY_FAILED_RETRY_SCHEDULED = {
775+ "event_type": "ping", "successful": false,
776+ "error_message": "Bad HTTP response: 404",
777+ "date_sent": "2014-09-08T01:19:16+00:00",
778+ "self_link": "http://example.com/delivery/2",
779+ "date_created": "2014-09-08T01:19:16+00:00",
780+ "date_scheduled": "2034-09-08T01:19:16+00:00", "pending": true,
781+ "resource_type_link": "#webhook_delivery"};
782+
783+ var common_test_methods = {
784+
785+ setUp: function() {
786+ Y.one("#fixture").append(
787+ Y.Node.create(Y.one("#fixture-template").getContent()));
788+ this.widget = this.createWidget();
789+ },
790+
791+ tearDown: function() {
792+ this.widget.destroy();
793+ Y.one("#fixture").empty();
794+ },
795+
796+ createWidget: function(cfg) {
797+ var config = Y.merge(cfg, {
798+ srcNode: "#webhook-deliveries",
799+ });
800+ var ns = Y.lp.services.webhooks.deliveries;
801+ return new ns.WebhookDeliveries(config);
802+ }
803+
804+ };
805+
806+ tests.suite.add(new Y.Test.Case(Y.merge(common_test_methods, {
807+ name: 'lp.services.webhooks.deliveries_tests',
808+
809+ test_library_exists: function () {
810+ Y.Assert.isObject(Y.lp.services.webhooks.deliveries,
811+ "Could not locate the " +
812+ "lp.services.webhooks.deliveries module");
813+ },
814+
815+ test_widget_can_be_instantiated: function() {
816+ Y.Assert.isInstanceOf(
817+ Y.lp.services.webhooks.deliveries.WebhookDeliveries,
818+ this.widget, "Widget failed to be instantiated");
819+ },
820+
821+ test_render: function() {
822+ Y.Assert.isFalse(Y.all("#webhook-deliveries tr").isEmpty());
823+ Y.Assert.isNotNull(Y.one("#webhook-deliveries-table-loading"));
824+ this.widget.render();
825+ Y.Assert.isTrue(Y.all("#webhook-deliveries tr").isEmpty());
826+ Y.Assert.isNull(Y.one("#webhook-deliveries-table-loading"));
827+ Y.ArrayAssert.itemsAreEqual(this.widget.get("deliveries"), []);
828+ },
829+
830+ test_expand_detail: function() {
831+ this.widget.render();
832+ Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 0);
833+ this.widget.set("deliveries", [DELIVERY_PENDING]);
834+ Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 1);
835+ // Clicking a row adds a new one immediately below with details.
836+ Y.one("#webhook-deliveries tr:nth-child(1)").simulate("click");
837+ Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 2);
838+ // Clicking on the new row does nothing.
839+ Y.one("#webhook-deliveries tr:nth-child(2)").simulate("click");
840+ Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 2);
841+
842+ // Adding and clicking another row expands it as well.
843+ this.widget.set("deliveries", [DELIVERY_PENDING, DELIVERY_FAILED]);
844+ Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 3);
845+ Y.one("#webhook-deliveries tr:nth-child(3)").simulate("click");
846+ Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 4);
847+ // Clicking the main row again collapses it.
848+ Y.one("#webhook-deliveries tr:nth-child(1)").simulate("click");
849+ Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 3);
850+
851+ // The expanded state is remembered even if the deliveries
852+ // disappear.
853+ this.widget.set("deliveries", []);
854+ Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 0);
855+ this.widget.set("deliveries", [DELIVERY_PENDING, DELIVERY_FAILED]);
856+ Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 3);
857+ },
858+
859+ dump_row_state: function(node) {
860+ // XXX wgrant 2015-09-09: Should get the detail row in a
861+ // nicer way.
862+ Y.Assert.isNull(Y.one("#webhook-deliveries tr:nth-child(2)"));
863+ // Expand the detail section.
864+ node.simulate("click");
865+ var detail_node = Y.one("#webhook-deliveries tr:nth-child(2)");
866+ Y.Assert.isObject(detail_node);
867+ var delivering_notice = detail_node.one(
868+ ".delivery-delivering-notice");
869+ Y.Assert.isNotNull(delivering_notice);
870+ var retry_notice = detail_node.one(".delivery-retry-notice");
871+ var retry = detail_node.one(".delivery-retry");
872+ return {
873+ sprite: node.one("td span.sprite").get("className"),
874+ delivering: !delivering_notice.hasClass("hidden"),
875+ retry_notice: retry_notice.hasClass("hidden")
876+ ? null : retry_notice.get("text"),
877+ retry: retry.hasClass("hidden") ? null : retry.get("text")
878+ };
879+ },
880+
881+ test_delivery_pending: function() {
882+ this.widget.set("deliveries", [DELIVERY_PENDING]);
883+ this.widget.render();
884+ Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 1);
885+ var state = this.dump_row_state(Y.one("#webhook-deliveries tr"));
886+ // Of the retry widgets, only the "Delivering" spinner is shown.
887+ Y.Assert.areEqual(state.sprite, "sprite milestone");
888+ Y.Assert.isTrue(state.delivering);
889+ Y.Assert.isNull(state.retry_notice);
890+ Y.Assert.isNull(state.retry);
891+ },
892+
893+ test_delivery_successful: function() {
894+ this.widget.set("deliveries", [DELIVERY_SUCCESSFUL]);
895+ this.widget.render();
896+ Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 1);
897+ var state = this.dump_row_state(Y.one("#webhook-deliveries tr"));
898+ // The only visible retry widget is the "Retry" link.
899+ Y.Assert.areEqual(state.sprite, "sprite yes");
900+ Y.Assert.isFalse(state.delivering);
901+ Y.Assert.isNull(state.retry_notice);
902+ Y.Assert.areEqual(state.retry, "Retry");
903+ },
904+
905+ test_delivery_successful_retry_now: function() {
906+ this.widget.set("deliveries", [DELIVERY_SUCCESSFUL_RETRY_NOW]);
907+ this.widget.render();
908+ Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 1);
909+ var state = this.dump_row_state(Y.one("#webhook-deliveries tr"));
910+ // The "Delivering" spinner is visible.
911+ Y.Assert.areEqual(state.sprite, "sprite yes");
912+ Y.Assert.isTrue(state.delivering);
913+ Y.Assert.isNull(state.retry_notice);
914+ Y.Assert.isNull(state.retry);
915+ },
916+
917+ test_delivery_failed: function() {
918+ this.widget.set("deliveries", [DELIVERY_FAILED]);
919+ this.widget.render();
920+ Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 1);
921+ var state = this.dump_row_state(Y.one("#webhook-deliveries tr"));
922+ // The only visible retry widget is the "Retry" link.
923+ Y.Assert.areEqual(state.sprite, "sprite no");
924+ Y.Assert.isFalse(state.delivering);
925+ Y.Assert.isNull(state.retry_notice);
926+ Y.Assert.areEqual(state.retry, "Retry");
927+ },
928+
929+ test_delivery_failed_retry_scheduled: function() {
930+ this.widget.set("deliveries", [DELIVERY_FAILED_RETRY_SCHEDULED]);
931+ this.widget.render();
932+ Y.Assert.areEqual(Y.all("#webhook-deliveries tr").size(), 1);
933+ var state = this.dump_row_state(Y.one("#webhook-deliveries tr"));
934+ // The visible retry widgets are the schedule notice and a
935+ // "Retry now" link.
936+ Y.Assert.areEqual(state.sprite, "sprite warning-icon");
937+ Y.Assert.isFalse(state.delivering);
938+ Y.Assert.areEqual(state.retry_notice, "Retrying on 2034-09-08.");
939+ Y.Assert.areEqual(state.retry, "Retry now");
940+ }
941+
942+ })));
943+
944+}, '0.1', {'requires': ['test', 'test-console', 'event', 'node-event-simulate',
945+ 'lp.testing.mockio', 'lp.services.webhooks.deliveries']});
946
947=== modified file 'lib/lp/services/webhooks/model.py'
948--- lib/lp/services/webhooks/model.py 2015-08-10 08:09:09 +0000
949+++ lib/lp/services/webhooks/model.py 2015-09-09 13:04:04 +0000
950@@ -22,6 +22,7 @@
951 DBItem,
952 )
953 from pytz import utc
954+from storm.expr import Desc
955 from storm.properties import (
956 Bool,
957 DateTime,
958@@ -116,7 +117,7 @@
959 WebhookJob,
960 WebhookJob.webhook == self,
961 WebhookJob.job_type == WebhookJobType.DELIVERY,
962- ).order_by(WebhookJob.job_id)
963+ ).order_by(Desc(WebhookJob.job_id))
964
965 def preload_jobs(rows):
966 load_related(Job, rows, ['job_id'])
967@@ -361,6 +362,10 @@
968 return 'Bad HTTP response: %d' % status_code
969
970 @property
971+ def date_scheduled(self):
972+ return self.scheduled_start
973+
974+ @property
975 def date_first_sent(self):
976 if 'date_first_sent' not in self.json_data:
977 return None
978
979=== modified file 'lib/lp/services/webhooks/templates/webhook-index.pt'
980--- lib/lp/services/webhooks/templates/webhook-index.pt 2015-08-04 08:50:29 +0000
981+++ lib/lp/services/webhooks/templates/webhook-index.pt 2015-09-09 13:04:04 +0000
982@@ -5,6 +5,46 @@
983 xmlns:i18n="http://xml.zope.org/namespaces/i18n"
984 metal:use-macro="view/macro:page/main_only"
985 i18n:domain="launchpad">
986+<head>
987+ <metal:block fill-slot="head_epilogue">
988+ <script tal:content="structure string:
989+ LPJS.use('base', 'node', 'event', 'lp.services.webhooks.deliveries',
990+ function(Y) {
991+ Y.on('domready', function() {
992+ var ns = Y.lp.services.webhooks.deliveries;
993+ var deliveries_widget = new ns.WebhookDeliveries({
994+ srcNode: '#webhook-deliveries'});
995+
996+ // Set up the batch navigation controls.
997+ var container = Y.one('#webhook-deliveries');
998+ var navigator = new ns.WebhookDeliveriesListingNavigator({
999+ current_url: window.location,
1000+ cache: LP.cache,
1001+ target: Y.one('#webhook-deliveries-table'),
1002+ container: container,
1003+ });
1004+ navigator.set('backwards_navigation',
1005+ container.all('.first,.previous'));
1006+ navigator.set('forwards_navigation',
1007+ container.all('.last,.next'));
1008+ navigator.clickAction('.first', navigator.first_batch);
1009+ navigator.clickAction('.next', navigator.next_batch);
1010+ navigator.clickAction('.previous', navigator.prev_batch);
1011+ navigator.clickAction('.last', navigator.last_batch);
1012+ navigator.update_navigation_links();
1013+ navigator.subscribe(
1014+ ns.WebhookDeliveriesListingNavigator.UPDATE_CONTENT,
1015+ function(e) {
1016+ deliveries_widget.set('deliveries', e.details[0]);
1017+ });
1018+
1019+ deliveries_widget.set('deliveries', LP.cache.deliveries);
1020+ deliveries_widget.render();
1021+ });
1022+ });
1023+ "/>
1024+ </metal:block>
1025+</head>
1026 <body>
1027 <div metal:fill-slot="main">
1028 <div metal:use-macro="context/@@launchpad_form/form">
1029@@ -17,6 +57,30 @@
1030 <a tal:attributes="href context/fmt:url/+delete">Delete webhook</a>
1031 </div>
1032 </div>
1033+ <h2>Recent deliveries</h2>
1034+ <div id="webhook-deliveries">
1035+ <div class="lesser"
1036+ tal:content="structure view/deliveries/@@+navigation-links-upper" />
1037+
1038+ <table id="webhook-deliveries-table" class="listing">
1039+ <colgroup>
1040+ <col style="width: 18px" />
1041+ <col style="width: 10em" />
1042+ <col style="width: 5em" />
1043+ </colgroup>
1044+ <tbody>
1045+ <tr id='webhook-deliveries-table-loading'>
1046+ <td colspan="3" style="padding-left: 0.25em">
1047+ <img class="spinner" src="/@@/spinner" alt="Loading..." />
1048+ Loading...
1049+ </td>
1050+ </tr>
1051+ </tbody>
1052+ </table>
1053+
1054+ <div class="lesser"
1055+ tal:content="structure view/deliveries/@@+navigation-links-lower" />
1056+ </div>
1057 </div>
1058 </body>
1059 </html>
1060
1061=== modified file 'lib/lp/services/webhooks/tests/test_webservice.py'
1062--- lib/lp/services/webhooks/tests/test_webservice.py 2015-08-10 08:01:22 +0000
1063+++ lib/lp/services/webhooks/tests/test_webservice.py 2015-09-09 13:04:04 +0000
1064@@ -215,9 +215,9 @@
1065 representation,
1066 MatchesAll(
1067 KeysEqual(
1068- 'date_created', 'date_first_sent', 'date_sent',
1069- 'error_message', 'event_type', 'http_etag', 'payload',
1070- 'pending', 'resource_type_link', 'self_link',
1071+ 'date_created', 'date_first_sent', 'date_scheduled',
1072+ 'date_sent', 'error_message', 'event_type', 'http_etag',
1073+ 'payload', 'pending', 'resource_type_link', 'self_link',
1074 'successful', 'web_link', 'webhook_link'),
1075 ContainsDict({
1076 'event_type': Equals('ping'),
1077@@ -225,6 +225,7 @@
1078 'pending': Equals(True),
1079 'successful': Is(None),
1080 'date_created': Not(Is(None)),
1081+ 'date_scheduled': Is(None),
1082 'date_sent': Is(None),
1083 'error_message': Is(None),
1084 })))
1085
1086=== added file 'lib/lp/services/webhooks/tests/test_yuitests.py'
1087--- lib/lp/services/webhooks/tests/test_yuitests.py 1970-01-01 00:00:00 +0000
1088+++ lib/lp/services/webhooks/tests/test_yuitests.py 2015-09-09 13:04:04 +0000
1089@@ -0,0 +1,26 @@
1090+# Copyright 2011-2015 Canonical Ltd. This software is licensed under the
1091+# GNU Affero General Public License version 3 (see the file LICENSE).
1092+
1093+"""Run YUI.test tests."""
1094+
1095+__metaclass__ = type
1096+__all__ = []
1097+
1098+from lp.testing import (
1099+ build_yui_unittest_suite,
1100+ YUIUnitTestCase,
1101+ )
1102+from lp.testing.layers import YUITestLayer
1103+
1104+
1105+class WebhooksYUIUnitTestCase(YUIUnitTestCase):
1106+
1107+ layer = YUITestLayer
1108+ suite_name = 'WebhooksYUIUnitTests'
1109+
1110+
1111+def test_suite():
1112+ app_testing_path = 'lp/services/webhooks'
1113+ return build_yui_unittest_suite(
1114+ app_testing_path,
1115+ WebhooksYUIUnitTestCase)