Merge lp:~wallyworld/launchpad/recipe-build-now into lp:launchpad

Proposed by Ian Booth
Status: Merged
Approved by: Ian Booth
Approved revision: no longer in the source branch.
Merged at revision: 12441
Proposed branch: lp:~wallyworld/launchpad/recipe-build-now
Merge into: lp:launchpad
Prerequisite: lp:~wallyworld/launchpad/request-build-popup
Diff against target: 1179 lines (+498/-134)
11 files modified
lib/canonical/launchpad/icing/icon-sprites.positioning (+56/-52)
lib/canonical/launchpad/icing/style-3-0.css.in (+8/-5)
lib/lp/code/browser/configure.zcml (+6/-0)
lib/lp/code/browser/sourcepackagerecipe.py (+140/-36)
lib/lp/code/browser/tests/test_sourcepackagerecipe.py (+83/-0)
lib/lp/code/interfaces/sourcepackagerecipe.py (+4/-0)
lib/lp/code/javascript/requestbuild_overlay.js (+118/-27)
lib/lp/code/model/sourcepackagerecipe.py (+14/-0)
lib/lp/code/templates/sourcepackagerecipe-index.pt (+36/-2)
lib/lp/code/windmill/tests/test_recipe_request_build.py (+26/-12)
lib/lp/testing/__init__.py (+7/-0)
To merge this branch: bzr merge lp:~wallyworld/launchpad/recipe-build-now
Reviewer Review Type Date Requested Status
Tim Penhey (community) code Approve
Henning Eggers (community) ui Approve
Review via email: mp+49968@code.launchpad.net

Commit message

[r=thumper][ui=henninge][bug=687623,720474] Add a link/button on the recipe index page to allow a daily build to be manually initiated.

Description of the change

This change provides a way to allow users to manually request that a daily build of a recipe should be queued "now". This feature is only available if the recipe is configured to build daily and is marked as stale.

= =Implementation ==

A new view was written - SourcePackageRecipeRequestDailyBuildView
This view handles both the ajax and non-ajax cases. The request.is_ajax flag is used to see if it's an ajax call. If so, a different page template is used to just load the recipe builds and this info is returned to be inserted directly into the page without a refresh. For the non-ajax case, a page refresh is used to reload the entire recipe page and hence show the new pending builds.

In the case where there is already a build for one or more of the distroseries pending, no error is shown. Any new builds are simply displayed.

A new sprite was added - sourcepackage-recipe-build.png. This is used as the icon to display next to the ajax "Build now" link.

A drive by fix was done to the recipe vocabularies for archives and distroseries since they borked when the recipe index page was shown without a user logged in.

== Demo and QA ==

The following screenshots show how the link/button looks for the script and no script case

http://people.canonical.com/~ianb/request-daily-build.png
http://people.canonical.com/~ianb/request-daily-build-no-ajax.png

To QA, you just need to ensure there's an appropriate recipe available (stale, build daily etc) and click the "Build now" link. The link should then disappear and the Recent Builds table be updated.

== Tests ==

A Windmill test was written to test that the ajax enabled "Build now" link works.
Unit tests were written to check that the "Build now" feature only shows when relevant and to check the no script version.

bin/test -vvt test_sourcepackagerecipe
bin/test -vvt test_recipe_request_build

To post a comment you must log in.
Henning Eggers (henninge) wrote :

Hi Ian,
thank you for this simple fix and taking the time to include some ajax and graceful degradation. It seems to work nicely but I do have a couple of issues I'd like to see addressed:

- I am not sure why the non-ajax case needs to include a button and does not get the nice new icon. Do we do that in other places already? The original idea was that ajax-enabled links are green, others blue and I think that still holds. This may be different here, though, because the link does not lead to a new page but simply starts an action on the same page. So the button may be OK if it is for that reason.
- The non-ajax case definitely needs some feed back. This is usually achieved by using the message boxes at the top of the page. "The build has been requested, see the Latest Builds table below." or something like that.
- The ajax case has a nice "requesting build" spinner when you click it but then just simply disappears. This leaves the user at a loss if the operation succeeded or not. (Can it fail btw?) A possible feedback might be to flash the Latest Builds table green, or rather the lines of the builds being started.
- The feed-back problem also made me think if the button is placed correctly in the first place. I actually think it should be placed by the table (which represents operational data about the builds) and not in the recipe information (which represents static data about the recipe). While writing this, I became very sure that it has to be moved. It should go right under the "Latest builds" heading in a line expressing the current state ("it is stale" but worded better ;).

On the whole this fix is a really nice start but I have to ask you to please fix the feed-back deficiencies before I can sign this off.

Cheers,
Henning

review: Needs Fixing (ui)
Ian Booth (wallyworld) wrote :

Hi Henning

Thanks for the detailed feedback. It's appreciated.

On 17/02/11 17:56, Henning Eggers wrote:
> Review: Needs Fixing ui
> Hi Ian,
> thank you for this simple fix and taking the time to include some ajax and graceful degradation. It seems to work nicely but I do have a couple of issues I'd like to see addressed:
>
> - I am not sure why the non-ajax case needs to include a button and does not get the nice new icon. Do we do that in other places already? The original idea was that ajax-enabled links are green, others blue and I think that still holds. This may be different here, though, because the link does not lead to a new page but simply starts an action on the same page. So the button may be OK if it is for that reason.

A form submission (post) is required for this operation in order to
start a write transaction. The issue is that without javascript a form
submission can be done with either a button or an image, but not a link.
In other similar places to this, we use a button (eg the vote or claim
review buttons on the mp page). Since buttons are used elsewhere for
this type of operation, I stuck with that convention. The alternative
was to go with an image that is done to look like a button or similar
but that's not done anywhere else. Because a post is required, a (blue)
link is not sufficient since that only does a get and starts a readonly
transaction.

> - The non-ajax case definitely needs some feed back. This is usually achieved by using the message boxes at the top of the page. "The build has been requested, see the Latest Builds table below." or something like that.

Good idea. Thanks. I'll do that.

> - The ajax case has a nice "requesting build" spinner when you click it but then just simply disappears. This leaves the user at a loss if the operation succeeded or not. (Can it fail btw?) A possible feedback might be to flash the Latest Builds table green, or rather the lines of the builds being started.

Any new builds rows are indeed flashed green using the standard yui
anim() call that we use in lp. Note that if any daily builds which
result from the Build Now click are already pending, they are not
reflashed. However this should be very rare,since the Build Now only
appears if the recipe is stale. Perhaps you missed seeing the row animation?

> - The feed-back problem also made me think if the button is placed correctly in the first place. I actually think it should be placed by the table (which represents operational data about the builds) and not in the recipe information (which represents static data about the recipe). While writing this, I became very sure that it has to be moved. It should go right under the "Latest builds" heading in a line expressing the current state ("it is stale" but worded better ;).
>

Ok. I'll mock something up and see how that looks.

> On the whole this fix is a really nice start but I have to ask you to please fix the feed-back deficiencies before I can sign this off.
>

No problem. The feedback was very good. Thanks.

Ian Booth (wallyworld) wrote :

>
> > - The feed-back problem also made me think if the button is placed correctly
> in the first place. I actually think it should be placed by the table (which
> represents operational data about the builds) and not in the recipe
> information (which represents static data about the recipe). While writing
> this, I became very sure that it has to be moved. It should go right under the
> "Latest builds" heading in a line expressing the current state ("it is stale"
> but worded better ;).
> >
>
> Ok. I'll mock something up and see how that looks.
>

A further thought on this. The Build Now link was put next to the recipe build_daily field on the ui because the two belong together. The Build Now link is only shown if the build_daily field is true. So it makes sense they are rendered together. The trouble with putting it in the builds table is that it decreases the visual association with the build_daily field which controls its existence. Also, using the words "is stale" or similar has a negative connotation and suggests to the user that action is required on their part to "correct" some problem. But there's no real problem as such and users may be enticed to keep selecting Build Now when the next scheduled daily build will take care of it. If the browser window is a largish size, then the new build rows will be visible when they are flashed green so that does give feedback that "something" has happended.

Henning Eggers (henninge) wrote :

Re: the non-ajax button

I had not made the connection to the form-submitting issue. In that case the button is appropriate, of course, as has been done elsewhere.

Re: Location of the link

I don't think the configurational relation of the daily build option and the availability of the action weighs heavier than the work flow for the user. Requesting an action must result in some visible change for the user but simply the option disappearing is not enough. That's why I think it should be moved to the table which is directly affected by the action. We cannot rely on a "largish size" of the browser window. On my laptop screen the top of the table is barely visible, even using chromium. Also, I just tried it a again and did not see a green flash at all but that might just me playing with the database to set the "stale" flag. ;-)

Re: "stale"

Sorry, I did not mean to put "stale" in the UI that's what I meant by "but worded better".

How about this (I am not sure if I got the wording right, though):

"<icon> There are no pending builds. _Build_now_"

After clicking _Build_now_:

"<icon> Build queued."
or
"<icon> Build pending"
or
"<icon> Next build in 10 hours" (if that is possible).

I could even go along with that being done in the old place but I am still not totally happy with mixing static and operational information. Also, it is probably doubling information from the Latest build table? My preference remains to place it on top of the table.

Also, I just noticed something else. Changing "Built daily" to "Built on request" (ajax popup) and back does not hide or show the new link. It takes a page reload to hide/show it. For the story to be complete, this should work in ajax, too.

Henning

Ian Booth (wallyworld) wrote :

Hi again

> RE: placement of Build Now button/link

I'll talk to Tim about the (valid) issues you raise and see what his
preferred option is etc.

>
> Also, I just noticed something else. Changing "Built daily" to "Built on request" (ajax popup) and back does not hide or show the new link. It takes a page reload to hide/show it. For the story to be complete, this should work in ajax, too.
>

Yes, this is a current known limitation and we are waiting on work by
Leonard on the javascript/webservices side before this issue can be
addressed. Good job on noticing it :-)

Tim Penhey (thumper) wrote :

Henning,

This has all come from me, and Ian has provided what I asked for.

This is a specific action that only applies to daily builds, and as such, it should be next to the visual representation of the build schedule. This is where I want it.

We do have a "Request builds" action that is either in or next to the build table.

Tim

Tim Penhey (thumper) wrote :

As discussed over mumble, there are a few things to fix.

review: Needs Fixing
Ian Booth (wallyworld) wrote :

Henning,

I've added a page notification for the html request build form and also the Build now button to give feedback since the new build rows cannot be flashed green.

http://people.canonical.com/~ianb/new-build-notification.png

I've kept the message simple: "x new recipe builds have been queued."
I experimented with attempting to list the new builds but in the end think simple is best.

Henning Eggers (henninge) wrote :

Tim,
ok, I understand the reasoning for the placement of the link and I am happy to go along with it now. What I cannot go along with, though, is that it simply disappears when the user clicks on it. I am using a larger screen today and so I see the green flash on the table but I am certain that users will be be missing it just like I did on my smaller laptop screen yesterday. Or they don't pay attention. Or they have tunnel vision. ;-) In any case, the action needs a feedback about success or failure in the same place where it is requested. So it should go: <icon> Build now -> <spinner> Requesting build ... -> <checkmark or info> 2 builds have been queued OR <error> Request failed. That response does not have to survive a page reload, though.

Ian,
the page notification looks good, thanks for adding that. ;)

Henning

Ian Booth (wallyworld) wrote :

> don't pay attention. Or they have tunnel vision. ;-) In any case, the action
> needs a feedback about success or failure in the same place where it is
> requested. So it should go: <icon> Build now -> <spinner> Requesting build ...
> -> <checkmark or info> 2 builds have been queued OR <error> Request failed.
> That response does not have to survive a page reload, though.

Hi Henning

I have added an informational message which appears when the ajax "Build now" action completes. The screenshot below shows how it looks:

http://people.canonical.com/~ianb/new-build-ajax-notification.png

This message replaces the "Requesting build..." spinner once the request completes and uses a timer so that after 20 seconds, the message disappears.

Hopefully this is sufficient :-)

Henning Eggers (henninge) wrote :

Looks great, thanks for adding this. ;-)

review: Approve (ui)
Tim Penhey (thumper) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/canonical/launchpad/icing/icon-sprites'
2Binary files lib/canonical/launchpad/icing/icon-sprites 2010-06-11 18:39:35 +0000 and lib/canonical/launchpad/icing/icon-sprites 2011-02-23 10:25:23 +0000 differ
3=== modified file 'lib/canonical/launchpad/icing/icon-sprites.positioning'
4--- lib/canonical/launchpad/icing/icon-sprites.positioning 2010-10-31 20:18:45 +0000
5+++ lib/canonical/launchpad/icing/icon-sprites.positioning 2011-02-23 10:25:23 +0000
6@@ -3,7 +3,7 @@
7 {
8 "../images/arrowLeft.png": [
9 0,
10- -14754
11+ -14918
12 ],
13 "../images/cancel.png": [
14 0,
15@@ -13,9 +13,9 @@
16 0,
17 -3440
18 ],
19- "../images/zoom-out.png": [
20+ "../images/build-needed.png": [
21 0,
22- -11966
23+ -13442
24 ],
25 "../images/team.png": [
26 0,
27@@ -43,7 +43,7 @@
28 ],
29 "../images/arrowTop.png": [
30 0,
31- -14426
32+ -14590
33 ],
34 "../images/zoom-in.png": [
35 0,
36@@ -55,23 +55,23 @@
37 ],
38 "../images/blue-bar.png": [
39 0,
40- -14918
41+ -15082
42 ],
43 "../images/arrowStart.png": [
44 0,
45- -14098
46+ -14262
47 ],
48 "../images/ppa-icon-inactive.png": [
49 0,
50 -12458
51 ],
52- "../images/build-needed.png": [
53+ "../images/zoom-out.png": [
54 0,
55- -13278
56+ -11966
57 ],
58 "../images/purple-bar.png": [
59 0,
60- -15246
61+ -15410
62 ],
63 "../images/bullet.png": [
64 0,
65@@ -79,11 +79,11 @@
66 ],
67 "../images/info-large.png": [
68 0,
69- -17340
70+ -17504
71 ],
72 "../images/trash-logo.png": [
73 0,
74- -20358
75+ -20340
76 ],
77 "../images/warning.png": [
78 0,
79@@ -95,35 +95,35 @@
80 ],
81 "../images/build-failure.png": [
82 0,
83- -13442
84+ -13606
85 ],
86 "../images/branch-large.png": [
87 0,
88- -16066
89+ -16230
90 ],
91 "../images/download-large.png": [
92 0,
93- -17158
94+ -17322
95 ],
96 "../images/private-large.png": [
97 0,
98- -18250
99+ -18232
100 ],
101 "../images/launchpad-large.png": [
102 0,
103- -17522
104+ -17686
105 ],
106 "../images/translation-file.png": [
107 0,
108 -10818
109 ],
110- "../images/read-only.png": [
111+ "../images/source-package-recipe.png": [
112 0,
113- -9998
114+ -12622
115 ],
116 "../images/project-logo.png": [
117 0,
118- -18860
119+ -18842
120 ],
121 "../images/bug-medium.png": [
122 0,
123@@ -135,7 +135,7 @@
124 ],
125 "../images/tour-icon": [
126 0,
127- -15902
128+ -16066
129 ],
130 "../images/trash-icon.png": [
131 0,
132@@ -147,7 +147,7 @@
133 ],
134 "../images/arrowBottom.png": [
135 0,
136- -14590
137+ -14754
138 ],
139 "../images/project.png": [
140 0,
141@@ -173,17 +173,13 @@
142 0,
143 -3768
144 ],
145- "../images/stop.png": [
146- 0,
147- -11310
148- ],
149 "../images/person-logo.png": [
150 0,
151- -19288
152+ -19270
153 ],
154 "../images/distribution-logo.png": [
155 0,
156- -18646
157+ -18628
158 ],
159 "../images/retry.png": [
160 0,
161@@ -199,7 +195,7 @@
162 ],
163 "../images/merge-proposal-icon.png": [
164 0,
165- -12786
166+ -12950
167 ],
168 "../images/download.png": [
169 0,
170@@ -207,7 +203,7 @@
171 ],
172 "../images/arrowDown.png": [
173 0,
174- -13934
175+ -14098
176 ],
177 "../images/package-binary.png": [
178 0,
179@@ -219,11 +215,11 @@
180 ],
181 "../images/bug-status-expand.png": [
182 0,
183- -12622
184+ -12786
185 ],
186 "../images/crowd-large.png": [
187 0,
188- -16430
189+ -16594
190 ],
191 "../images/blueprint.png": [
192 0,
193@@ -245,9 +241,13 @@
194 0,
195 -6228
196 ],
197+ "../images/stop.png": [
198+ 0,
199+ -11310
200+ ],
201 "../images/flame-large.png": [
202 0,
203- -16976
204+ -17140
205 ],
206 "../images/bug-dupe-icon.png": [
207 0,
208@@ -259,11 +259,11 @@
209 ],
210 "../images/build-success.png": [
211 0,
212- -13114
213+ -13278
214 ],
215 "../images/haspatch-icon.png": [
216 0,
217- -15738
218+ -15902
219 ],
220 "../images/person-inactive-badge.png": [
221 0,
222@@ -279,7 +279,7 @@
223 ],
224 "../images/team-logo.png": [
225 0,
226- -19716
227+ -19698
228 ],
229 "../images/arrowRight.png": [
230 0,
231@@ -311,7 +311,7 @@
232 ],
233 "../images/arrowUp.png": [
234 0,
235- -13770
236+ -13934
237 ],
238 "../images/distribution.png": [
239 0,
240@@ -319,11 +319,11 @@
241 ],
242 "../images/error-large.png": [
243 0,
244- -16794
245+ -16958
246 ],
247 "../images/news.png": [
248 0,
249- -15574
250+ -15738
251 ],
252 "../images/treeExpanded.png": [
253 0,
254@@ -331,7 +331,7 @@
255 ],
256 "../images/build-depwait.png": [
257 0,
258- -13606
259+ -13770
260 ],
261 "../images/blueprint-essential.png": [
262 0,
263@@ -351,7 +351,7 @@
264 ],
265 "../images/product-logo.png": [
266 0,
267- -19074
268+ -19056
269 ],
270 "../images/blueprint-medium.png": [
271 0,
272@@ -367,11 +367,11 @@
273 ],
274 "../images/launchpad-logo.png": [
275 0,
276- -18432
277+ -18414
278 ],
279 "../images/flame-logo.png": [
280 0,
281- -20144
282+ -20126
283 ],
284 "../images/translation-template.png": [
285 0,
286@@ -383,7 +383,7 @@
287 ],
288 "../images/meeting-logo.png": [
289 0,
290- -19930
291+ -19912
292 ],
293 "../images/treeCollapsed.png": [
294 0,
295@@ -391,19 +391,19 @@
296 ],
297 "../images/green-bar.png": [
298 0,
299- -15082
300+ -15246
301 ],
302 "../images/build-superseded.png": [
303 0,
304- -12950
305+ -13114
306 ],
307 "../images/trash-large.png": [
308 0,
309- -18068
310+ -18050
311 ],
312 "../images/red-bar.png": [
313 0,
314- -15410
315+ -15574
316 ],
317 "../images/add.png": [
318 0,
319@@ -413,9 +413,13 @@
320 0,
321 -328
322 ],
323+ "../images/read-only.png": [
324+ 0,
325+ -9998
326+ ],
327 "../images/person-inactive-logo.png": [
328 0,
329- -19502
330+ -19484
331 ],
332 "../images/edit.png": [
333 0,
334@@ -427,11 +431,11 @@
335 ],
336 "../images/warning-large.png": [
337 0,
338- -16248
339+ -16412
340 ],
341 "../images/arrowEnd.png": [
342 0,
343- -14262
344+ -14426
345 ],
346 "../images/cve.png": [
347 0,
348@@ -439,7 +443,7 @@
349 ],
350 "../images/merge-proposal-large.png": [
351 0,
352- -17886
353+ -17868
354 ],
355 "../images/branch.png": [
356 0,
357@@ -469,4 +473,4 @@
358 0,
359 -1148
360 ]
361-}
362+}
363\ No newline at end of file
364
365=== modified file 'lib/canonical/launchpad/icing/style-3-0.css.in'
366--- lib/canonical/launchpad/icing/style-3-0.css.in 2011-02-14 07:33:54 +0000
367+++ lib/canonical/launchpad/icing/style-3-0.css.in 2011-02-23 10:25:23 +0000
368@@ -1471,10 +1471,10 @@
369 Colors and fonts
370 */
371
372-h1, h2, h3,
373-table.listing thead,
374-#homepage-stats strong,
375-#application-footer strong,
376+h1, h2, h3,
377+table.listing thead,
378+#homepage-stats strong,
379+#application-footer strong,
380 #application-summary strong {
381 color: #5a5a5a;
382 }
383@@ -2204,7 +2204,10 @@
384 background-image: url(/@@/ppa-icon-inactive.png); /* sprite-ref: icon-sprites */
385 background-repeat: no-repeat;
386 }
387-
388+.source-package-recipe {
389+ background-image: url(/@@/source-package-recipe.png); /* sprite-ref: icon-sprites */
390+ background-repeat: no-repeat;
391+ }
392 .bug-status-expand {
393 background-image: url(/@@/bug-status-expand.png); /* sprite-ref: icon-sprites */
394 background-repeat: no-repeat;
395
396=== modified file 'lib/lp/code/browser/configure.zcml'
397--- lib/lp/code/browser/configure.zcml 2011-02-10 04:16:09 +0000
398+++ lib/lp/code/browser/configure.zcml 2011-02-23 10:25:23 +0000
399@@ -1239,6 +1239,12 @@
400 name="+builds"
401 template="../templates/sourcepackagerecipe-builds.pt"
402 permission="launchpad.View"/>
403+ <browser:page
404+ for="lp.code.interfaces.sourcepackagerecipe.ISourcePackageRecipe"
405+ layer="lp.code.publisher.CodeLayer"
406+ class="lp.code.browser.sourcepackagerecipe.SourcePackageRecipeRequestDailyBuildView"
407+ name="+request-daily-build"
408+ permission="launchpad.Edit"/>
409 </facet>
410 <facet facet="branches">
411 <browser:defaultView
412
413=== modified file 'lib/lp/code/browser/sourcepackagerecipe.py'
414--- lib/lp/code/browser/sourcepackagerecipe.py 2011-02-22 09:07:44 +0000
415+++ lib/lp/code/browser/sourcepackagerecipe.py 2011-02-23 10:25:23 +0000
416@@ -180,12 +180,27 @@
417
418 facet = 'branches'
419
420- links = ('request_builds',)
421+ links = ('request_builds', 'request_daily_build',)
422
423 def request_builds(self):
424 """Provide a link for requesting builds of a recipe."""
425 return Link('+request-builds', 'Request build(s)', icon='add')
426
427+ def request_daily_build(self):
428+ """Provide a link for requesting a daily build of a recipe."""
429+ recipe = self.context
430+ ppa = recipe.daily_build_archive
431+ if (ppa is None or not recipe.build_daily or not recipe.is_stale
432+ or not recipe.distroseries):
433+ show_request_build = False
434+ else:
435+ has_upload = ppa.checkArchivePermission(recipe.owner)
436+ show_request_build = has_upload
437+
438+ return Link(
439+ '+request-daily-build', 'Build now',
440+ enabled=show_request_build)
441+
442
443 class SourcePackageRecipeView(LaunchpadView):
444 """Default view of a SourcePackageRecipe."""
445@@ -292,6 +307,17 @@
446 return builds
447
448
449+def new_builds_notification_text(builds):
450+ nr_builds = len(builds)
451+ if not nr_builds:
452+ builds_text = "All requested recipe builds are already queued."
453+ elif nr_builds == 1:
454+ builds_text = "1 new recipe build has been queued."
455+ else:
456+ builds_text = "%d new recipe builds have been queued." % nr_builds
457+ return builds_text
458+
459+
460 class SourcePackageRecipeRequestBuildsView(LaunchpadFormView):
461 """A view for requesting builds of a SourcePackageRecipe."""
462
463@@ -341,14 +367,16 @@
464 other builds can ne queued and a message be displayed to the caller.
465 """
466 errors = {}
467+ builds = []
468 for distroseries in data['distros']:
469 try:
470- self.context.requestBuild(
471+ build = self.context.requestBuild(
472 data['archive'], self.user, distroseries, manual=True)
473+ builds.append(build)
474 except BuildAlreadyPending, e:
475 errors['distros'] = ("An identical build is already pending "
476 "for %s." % e.distroseries)
477- return errors
478+ return builds, errors
479
480
481 class SourcePackageRecipeRequestBuildsHtmlView(
482@@ -367,44 +395,120 @@
483
484 @action('Request builds', name='request')
485 def request_action(self, action, data):
486- errors = self.requestBuild(data)
487+ builds, errors = self.requestBuild(data)
488 if errors:
489 [self.setFieldError(field, message)
490 for (field, message) in errors.items()]
491 return
492 self.next_url = self.cancel_url
493-
494-
495-class SourcePackageRecipeRequestBuildsAjaxView(
496- SourcePackageRecipeRequestBuildsView):
497- """Supports AJAX form recipe build requests."""
498-
499- def _process_error(self, data, errors, reason):
500- """Set up the response and json data to return to the caller."""
501- self.request.response.setStatus(400, reason)
502- self.request.response.setHeader('Content-type', 'application/json')
503- return simplejson.dumps(errors)
504-
505- def failure(self, action, data, errors):
506- """Called by the form if validate() finds any errors.
507-
508- We simply convert the errors to json and return that data to the
509- caller for display to the user.
510- """
511- return self._process_error(data, self.widget_errors, "Validation")
512-
513- @action('Request builds', name='request', failure=failure)
514- def request_action(self, action, data):
515- """User action for requesting a number of builds.
516-
517- The failure handler will handle any validation errors. We still need
518- to handle errors which may occur when invoking the business logic.
519- These "expected" errors are ones which result in a predefined message
520- being displayed to the user. If the business method raises an
521- unexpected exception, that will be handled using the form's standard
522- exception processing mechanism (using response code 500).
523- """
524- errors = self.requestBuild(data)
525+ self.request.response.addNotification(
526+ new_builds_notification_text(builds))
527+
528+
529+class SourcePackageRecipeRequestBuildsAjaxView(
530+ SourcePackageRecipeRequestBuildsView):
531+ """Supports AJAX form recipe build requests."""
532+
533+ def _process_error(self, data, errors, reason):
534+ """Set up the response and json data to return to the caller."""
535+ self.request.response.setStatus(400, reason)
536+ self.request.response.setHeader('Content-type', 'application/json')
537+ return simplejson.dumps(errors)
538+
539+ def failure(self, action, data, errors):
540+ """Called by the form if validate() finds any errors.
541+
542+ We simply convert the errors to json and return that data to the
543+ caller for display to the user.
544+ """
545+ return self._process_error(data, self.widget_errors, "Validation")
546+
547+ @action('Request builds', name='request', failure=failure)
548+ def request_action(self, action, data):
549+ """User action for requesting a number of builds.
550+
551+ The failure handler will handle any validation errors. We still need
552+ to handle errors which may occur when invoking the business logic.
553+ These "expected" errors are ones which result in a predefined message
554+ being displayed to the user. If the business method raises an
555+ unexpected exception, that will be handled using the form's standard
556+ exception processing mechanism (using response code 500).
557+ """
558+ builds, errors = self.requestBuild(data)
559+ # If there are errors we return a json data snippet containing the
560+ # errors instead of rendering the form. These errors are processed
561+ # by the caller's response handler and displayed to the user.
562+ if errors:
563+ return self._process_error(data, errors, "Request Build")
564+
565+ @property
566+ def builds(self):
567+ return builds_for_recipe(self.context)
568+
569+
570+class SourcePackageRecipeRequestDailyBuildView(LaunchpadFormView):
571+ """Supports requests to perform a daily build for a recipe.
572+
573+ Renders the recipe builds table so that the recipe index page can be
574+ updated with the new build records.
575+
576+ This view works for both ajax and html form requests.
577+ """
578+
579+ # Attributes for the html version
580+ page_title = "Build now"
581+
582+ class schema(Interface):
583+ """Schema for requesting a build."""
584+
585+ @action('Build now', name='build')
586+ def build_action(self, action, data):
587+ recipe = self.context
588+ builds = recipe.performDailyBuild()
589+ if self.request.is_ajax:
590+ template = ViewPageTemplateFile(
591+ "../templates/sourcepackagerecipe-builds.pt")
592+ return template(self)
593+ else:
594+ self.next_url = canonical_url(recipe)
595+ self.request.response.addNotification(
596+ new_builds_notification_text(builds))
597+
598+ @property
599+ def builds(self):
600+ return builds_for_recipe(self.context)
601+
602+
603+class SourcePackageRecipeRequestBuildsAjaxView(
604+ SourcePackageRecipeRequestBuildsView):
605+ """Supports AJAX form recipe build requests."""
606+
607+ def _process_error(self, data, errors, reason):
608+ """Set up the response and json data to return to the caller."""
609+ self.request.response.setStatus(400, reason)
610+ self.request.response.setHeader('Content-type', 'application/json')
611+ return simplejson.dumps(errors)
612+
613+ def failure(self, action, data, errors):
614+ """Called by the form if validate() finds any errors.
615+
616+ We simply convert the errors to json and return that data to the
617+ caller for display to the user.
618+ """
619+ return self._process_error(data, self.widget_errors, "Validation")
620+
621+ @action('Request builds', name='request', failure=failure)
622+ def request_action(self, action, data):
623+ """User action for requesting a number of builds.
624+
625+ The failure handler will handle any validation errors. We still need
626+ to handle errors which may occur when invoking the business logic.
627+ These "expected" errors are ones which result in a predefined message
628+ being displayed to the user. If the business method raises an
629+ unexpected exception, that will be handled using the form's standard
630+ exception processing mechanism (using response code 500).
631+ """
632+ builds, errors = self.requestBuild(data)
633 # If there are errors we return a json data snippet containing the
634 # errors instead of rendering the form. These errors are processed
635 # by the caller's response handler and displayed to the user.
636
637=== modified file 'lib/lp/code/browser/tests/test_sourcepackagerecipe.py'
638--- lib/lp/code/browser/tests/test_sourcepackagerecipe.py 2011-02-21 20:55:00 +0000
639+++ lib/lp/code/browser/tests/test_sourcepackagerecipe.py 2011-02-23 10:25:23 +0000
640@@ -26,6 +26,7 @@
641 from canonical.launchpad.testing.pages import (
642 extract_text,
643 find_main_content,
644+ find_tag_by_id,
645 find_tags_by_class,
646 get_feedback_messages,
647 get_radio_button_text_for_field,
648@@ -1227,6 +1228,88 @@
649 self.assertEqual(
650 [build1, build2, build3, build4, build5], view.builds)
651
652+ def test_request_daily_builds_button_stale(self):
653+ # Recipes that are stale and are built daily have a build now link
654+ recipe = self.factory.makeSourcePackageRecipe(
655+ owner=self.chef, daily_build_archive=self.ppa,
656+ is_stale=True, build_daily=True)
657+ browser = self.getViewBrowser(recipe)
658+ build_button = find_tag_by_id(browser.contents, 'field.actions.build')
659+ self.assertIsNot(None, build_button)
660+
661+ def test_request_daily_builds_button_not_stale(self):
662+ # Recipes that are not stale do not have a build now link
663+ login(ANONYMOUS)
664+ recipe = self.factory.makeSourcePackageRecipe(
665+ owner=self.chef, daily_build_archive=self.ppa,
666+ is_stale=False, build_daily=True)
667+ browser = self.getViewBrowser(recipe)
668+ build_button = find_tag_by_id(browser.contents, 'field.actions.build')
669+ self.assertIs(None, build_button)
670+
671+ def test_request_daily_builds_button_not_daily(self):
672+ # Recipes that are not built daily do not have a build now link
673+ login(ANONYMOUS)
674+ recipe = self.factory.makeSourcePackageRecipe(
675+ owner=self.chef, daily_build_archive=self.ppa,
676+ is_stale=True, build_daily=False)
677+ browser = self.getViewBrowser(recipe)
678+ build_button = find_tag_by_id(browser.contents, 'field.actions.build')
679+ self.assertIs(None, build_button)
680+
681+ def test_request_daily_builds_button_no_daily_ppa(self):
682+ # Recipes that have no daily build ppa do not have a build now link
683+ login(ANONYMOUS)
684+ recipe = self.factory.makeSourcePackageRecipe(
685+ owner=self.chef, is_stale=True, build_daily=True)
686+ naked_recipe = removeSecurityProxy(recipe)
687+ naked_recipe.daily_build_archive = None
688+ browser = self.getViewBrowser(recipe)
689+ build_button = find_tag_by_id(browser.contents, 'field.actions.build')
690+ self.assertIs(None, build_button)
691+
692+ def test_request_daily_builds_button_ppa_with_no_permissions(self):
693+ # Recipes that have a daily build ppa without upload permissions
694+ # do not have a build now link
695+ login(ANONYMOUS)
696+ distroseries = self.factory.makeSourcePackageRecipeDistroseries()
697+ person = self.factory.makePerson()
698+ daily_build_archive = self.factory.makeArchive(
699+ distribution=distroseries.distribution, owner=person)
700+ recipe = self.factory.makeSourcePackageRecipe(
701+ owner=self.chef, daily_build_archive=daily_build_archive,
702+ is_stale=True, build_daily=True)
703+ browser = self.getViewBrowser(recipe)
704+ build_button = find_tag_by_id(browser.contents, 'field.actions.build')
705+ self.assertIs(None, build_button)
706+
707+ def test_request_daily_builds_ajax_link_not_rendered(self):
708+ # The Build now link should not be rendered without javascript.
709+ recipe = self.factory.makeSourcePackageRecipe(
710+ owner=self.chef, daily_build_archive=self.ppa,
711+ is_stale=True, build_daily=True)
712+ browser = self.getViewBrowser(recipe)
713+ build_link = find_tag_by_id(browser.contents, 'request-daily-builds')
714+ self.assertIs(None, build_link)
715+
716+ def test_request_daily_builds_action(self):
717+ # Daily builds should be triggered when requested.
718+ recipe = self.factory.makeSourcePackageRecipe(
719+ owner=self.chef, daily_build_archive=self.ppa,
720+ is_stale=True, build_daily=True)
721+ browser = self.getViewBrowser(recipe)
722+ browser.getControl('Build now').click()
723+ login(ANONYMOUS)
724+ builds = recipe.getPendingBuilds()
725+ build_distros = [
726+ build.distroseries.displayname for build in builds]
727+ build_distros.sort()
728+ # Our recipe has a Warty distroseries
729+ self.assertEqual(['Warty'], build_distros)
730+ self.assertEqual(
731+ set([2505]),
732+ set(build.buildqueue_record.lastscore for build in builds))
733+
734 def test_request_builds_page(self):
735 """Ensure the +request-builds page is sane."""
736 recipe = self.makeRecipe()
737
738=== modified file 'lib/lp/code/interfaces/sourcepackagerecipe.py'
739--- lib/lp/code/interfaces/sourcepackagerecipe.py 2011-02-18 03:39:25 +0000
740+++ lib/lp/code/interfaces/sourcepackagerecipe.py 2011-02-23 10:25:23 +0000
741@@ -136,6 +136,10 @@
742 able to upload to the archive.
743 """
744
745+ @export_write_operation()
746+ def performDailyBuild():
747+ """Perform a build into the daily build archive."""
748+
749
750 class ISourcePackageRecipeEdit(Interface):
751 """ISourcePackageRecipe methods that require launchpad.Edit permission."""
752
753=== modified file 'lib/lp/code/javascript/requestbuild_overlay.js'
754--- lib/lp/code/javascript/requestbuild_overlay.js 2011-02-18 01:06:19 +0000
755+++ lib/lp/code/javascript/requestbuild_overlay.js 2011-02-23 10:25:23 +0000
756@@ -12,7 +12,8 @@
757
758 var lp_client;
759 var request_build_overlay = null;
760-var response_handler;
761+var request_build_response_handler;
762+var request_daily_build_response_handler;
763
764 function set_up_lp_client() {
765 if (lp_client === undefined) {
766@@ -55,7 +56,7 @@
767 }
768 };
769
770-namespace.connect_requestbuild = function() {
771+namespace.connect_requestbuilds = function() {
772
773 var request_build_handle = Y.one('#request-builds');
774 request_build_handle.addClass('js-action');
775@@ -80,26 +81,102 @@
776 request_build_overlay.render();
777 }
778 request_build_overlay.clearError();
779- var temp_spinner = [
780+ var loading_spinner = [
781 '<div id="temp-spinner">',
782 '<img src="/@@/spinner"/>Loading...',
783 '</div>'].join('');
784- request_build_overlay.form_node.set("innerHTML", temp_spinner);
785+ request_build_overlay.form_node.set("innerHTML", loading_spinner);
786 request_build_overlay.loadFormContentAndRender('+builds/++form++');
787 request_build_overlay.show();
788 });
789
790 // Wire up the processing hooks
791- response_handler = new RequestResponseHandler();
792- response_handler.clearProgressUI = function() {
793+ request_build_response_handler = new RequestResponseHandler();
794+ request_build_response_handler.clearProgressUI = function() {
795 destroy_temporary_spinner();
796 };
797- response_handler.showError = function(error) {
798+ request_build_response_handler.showError = function(error) {
799 request_build_overlay.showError(error);
800 Y.log(error);
801 };
802 };
803
804+var NO_BUILDS_MESSAGE = "All requested recipe builds are already queued.";
805+var ONE_BUILD_MESSAGE = "1 new recipe build has been queued.";
806+var MANY_BUILDS_MESSAGE = "{nr_new} new recipe builds have been queued.";
807+
808+namespace.connect_requestdailybuild = function() {
809+
810+ var request_daily_build_handle = Y.one('#request-daily-build');
811+ request_daily_build_handle.on('click', function(e) {
812+ e.preventDefault();
813+
814+ create_temporary_spinner(
815+ "Requesting build...", request_daily_build_handle);
816+ request_daily_build_handle.addClass("unseen");
817+
818+ var base_url = LP.client.cache.context.web_link;
819+ var submit_url = base_url+"/+request-daily-build";
820+ var current_builds = harvest_current_build_records();
821+
822+ var qs = LP.client.append_qs('', 'field.actions.build', 'Build now');
823+ var y_config = {
824+ method: "POST",
825+ headers: {'Accept': 'application/xhtml'},
826+ on: {
827+ failure: request_daily_build_response_handler.getErrorHandler(
828+ function(handler, id, response) {
829+ request_daily_build_handle.removeClass("unseen");
830+ var server_error = 'Server error, ' +
831+ 'please contact an administrator.';
832+ handler.showError(server_error);
833+ }),
834+ success:
835+ request_daily_build_response_handler.getSuccessHandler(
836+ function(handler, id, response) {
837+ var nr_new = display_build_records(
838+ response.responseText, current_builds);
839+ var new_builds_message;
840+ switch (nr_new) {
841+ case 0:
842+ new_builds_message = NO_BUILDS_MESSAGE;
843+ break;
844+ case 1:
845+ new_builds_message = ONE_BUILD_MESSAGE;
846+ break;
847+ default:
848+ new_builds_message =
849+ Y.Lang.substitute(
850+ MANY_BUILDS_MESSAGE,
851+ {nr_new: nr_new});
852+ }
853+ var build_message_node = Y.Node.create([
854+ '<div id="new-builds-info" class="build-informational">',
855+ new_builds_message,
856+ '</div>'].join(''));
857+ request_daily_build_handle.insert(
858+ build_message_node,
859+ request_daily_build_handle);
860+ Y.later(20000, build_message_node, 'hide', true);
861+ }
862+ )
863+ },
864+ data: qs
865+ };
866+ Y.io(submit_url, y_config);
867+ });
868+
869+ // Wire up the processing hooks
870+ request_daily_build_response_handler = new RequestResponseHandler();
871+ request_daily_build_response_handler.clearProgressUI = function() {
872+ destroy_temporary_spinner();
873+ };
874+ request_daily_build_response_handler.showError = function(error) {
875+ alert(error);
876+ Y.log(error);
877+ };
878+};
879+
880 /*
881 * A function to return the current build records as displayed on the page
882 */
883@@ -118,14 +195,34 @@
884 }
885
886 /*
887+ * Render build records and flash the new ones
888+ */
889+function display_build_records(build_records_markup, current_builds) {
890+ var target = Y.one('#builds-target');
891+ target.set('innerHTML', build_records_markup);
892+ var new_builds = harvest_current_build_records();
893+ var nr_new_builds = 0;
894+ Y.Array.each(new_builds, function(row_id) {
895+ if( current_builds.indexOf(row_id)>=0 )
896+ return;
897+ nr_new_builds += 1;
898+ var row = Y.one('#'+row_id);
899+ var anim = Y.lazr.anim.green_flash({node: row});
900+ anim.run();
901+ });
902+ return nr_new_builds;
903+}
904+
905+/*
906 * Perform any client side validation
907 * Return: true if data is valid
908 */
909 function validate(data) {
910 var distros = data['field.distros']
911 if (Y.Object.size(distros) == 0) {
912- response_handler.showError("You need to specify at least one " +
913- "distro series for which to build.");
914+ request_build_response_handler.showError(
915+ "You need to specify at least one distro series for " +
916+ "which to build.");
917 return false;
918 }
919 return true;
920@@ -137,7 +234,9 @@
921 function do_request_builds(data) {
922 if (!validate(data))
923 return;
924- create_temporary_spinner();
925+ var spinner_location = Y.one('.yui3-lazr-formoverlay-actions');
926+ create_temporary_spinner("Requesting builds...", spinner_location);
927+
928 var base_url = LP.client.cache.context.web_link;
929 var submit_url = base_url+"/+builds";
930 var current_builds = harvest_current_build_records();
931@@ -145,14 +244,14 @@
932 method: "POST",
933 headers: {'Accept': 'application/json; application/xhtml'},
934 on: {
935- failure: response_handler.getErrorHandler(
936+ failure: request_build_response_handler.getErrorHandler(
937 function(handler, id, response) {
938 if( response.status >= 500 ) {
939 // There's some error content we need to display.
940 request_build_overlay.set(
941 'form_content', response.responseText);
942 request_build_overlay.get("form_submit_button")
943- .setStyle('display', 'none');
944+ .addClass('unseen');
945 request_build_overlay.renderUI();
946 //We want to force the form to be re-created
947 request_build_overlay = null;
948@@ -164,19 +263,11 @@
949 errors.push(error_info[field_name]);
950 handler.showError(errors);
951 }),
952- success: response_handler.getSuccessHandler(
953+ success: request_build_response_handler.getSuccessHandler(
954 function(handler, id, response) {
955 request_build_overlay.hide();
956- var target = Y.one('#builds-target');
957- target.set('innerHTML', response.responseText);
958- var new_builds = harvest_current_build_records();
959- Y.Array.each(new_builds, function(row_id) {
960- if (current_builds.indexOf(row_id)>=0)
961- return;
962- var row = Y.one('#'+row_id);
963- var anim = Y.lazr.anim.green_flash({node: row});
964- anim.run();
965- });
966+ display_build_records(
967+ response.responseText, current_builds)
968 })
969 },
970 form: {
971@@ -190,14 +281,14 @@
972 /*
973 * Show the temporary "Requesting..." text
974 */
975-function create_temporary_spinner() {
976+function create_temporary_spinner(text, node) {
977 // Add the temp "Requesting build..." text
978 var temp_spinner = Y.Node.create([
979 '<div id="temp-spinner">',
980- '<img src="/@@/spinner"/>Requesting build...',
981+ '<img src="/@@/spinner"/>',
982+ text,
983 '</div>'].join(''));
984- var request_build_handle = Y.one('.yui3-lazr-formoverlay-actions');
985- request_build_handle.insert(temp_spinner, request_build_handle);
986+ node.insert(temp_spinner, node);
987 }
988
989 /*
990
991=== modified file 'lib/lp/code/model/sourcepackagerecipe.py'
992--- lib/lp/code/model/sourcepackagerecipe.py 2011-02-18 23:51:57 +0000
993+++ lib/lp/code/model/sourcepackagerecipe.py 2011-02-23 10:25:23 +0000
994@@ -281,6 +281,20 @@
995 queue_record.manualScore(queue_record.lastscore + 100)
996 return build
997
998+ def performDailyBuild(self):
999+ """See `ISourcePackageRecipe`."""
1000+ builds = []
1001+ self.is_stale = False
1002+ for distroseries in self.distroseries:
1003+ try:
1004+ build = self.requestBuild(
1005+ self.daily_build_archive, self.owner,
1006+ distroseries, PackagePublishingPocket.RELEASE)
1007+ builds.append(build)
1008+ except BuildAlreadyPending:
1009+ continue
1010+ return builds
1011+
1012 def getBuilds(self):
1013 """See `ISourcePackageRecipe`."""
1014 where_clause = BuildFarmJob.status != BuildStatus.NEEDSBUILD
1015
1016=== modified file 'lib/lp/code/templates/sourcepackagerecipe-index.pt'
1017--- lib/lp/code/templates/sourcepackagerecipe-index.pt 2011-02-22 09:07:44 +0000
1018+++ lib/lp/code/templates/sourcepackagerecipe-index.pt 2011-02-23 10:25:23 +0000
1019@@ -16,6 +16,18 @@
1020 font-family: "UbuntuBeta Mono","Ubuntu Mono",monospace;
1021 margin-top: -15px;
1022 }
1023+ .build-informational {
1024+ background: #d4e8ff url('/+icing/blue-fade-to-grey');
1025+ border: solid #666;
1026+ border-width: 1px 2px 2px 1px;
1027+ color: black;
1028+ padding: 5px 5px 5px 5px;
1029+ margin-right: 40px
1030+ }
1031+ .build-informational::before {
1032+ padding-right: 5px;
1033+ content: url('/@@/info');
1034+ }
1035 </style>
1036 </metal:block>
1037
1038@@ -60,6 +72,23 @@
1039 </a>
1040 </dt>
1041 <dd tal:content="structure view/daily_build_widget"/>
1042+ <dd
1043+ tal:define="link context/menu:context/request_daily_build"
1044+ tal:condition="link/enabled"
1045+ >
1046+ <noscript>
1047+ <form action="+request-daily-build"
1048+ method="post"
1049+ id="request-daily-build-form">
1050+ <input type="submit" name="field.actions.build"
1051+ id="field.actions.build" value="Build now" />
1052+ </form>
1053+ </noscript>
1054+ <a id="request-daily-build"
1055+ class="sprite source-package-recipe js-action unseen"
1056+ tal:attributes="href link/url"
1057+ tal:content="link/text"/>
1058+ </dd>
1059 </dl>
1060
1061 <dl id="owner">
1062@@ -116,11 +145,16 @@
1063 if(Y.UA.ie) {
1064 return;
1065 }
1066-
1067 Y.on('load', function() {
1068 var logged_in = LP.client.links['me'] !== undefined;
1069 if (logged_in) {
1070- Y.lp.code.requestbuild_overlay.connect_requestbuild();
1071+ build_now_link = Y.one('#request-daily-build');
1072+ if( build_now_link != null ) {
1073+ build_now_link.removeClass('unseen');
1074+ Y.lp.code.requestbuild_overlay.connect_requestdailybuild();
1075+ }
1076+ Y.lp.code.requestbuild_overlay.connect_requestbuilds();
1077+
1078 }
1079 }, window);
1080 });"
1081
1082=== modified file 'lib/lp/code/windmill/tests/test_recipe_request_build.py'
1083--- lib/lp/code/windmill/tests/test_recipe_request_build.py 2011-02-18 01:06:19 +0000
1084+++ lib/lp/code/windmill/tests/test_recipe_request_build.py 2011-02-23 10:25:23 +0000
1085@@ -13,13 +13,12 @@
1086 from lp.testing.windmill.constants import (
1087 FOR_ELEMENT,
1088 PAGE_LOAD,
1089- SLEEP,
1090 )
1091 from lp.testing.windmill.lpuser import login_person
1092 from lp.app.browser.tales import PPAFormatterAPI
1093 from lp.code.windmill.testing import CodeWindmillLayer
1094 from lp.soyuz.model.processor import ProcessorFamily
1095-from lp.testing import WindmillTestCase
1096+from lp.testing import WindmillTestCase, quote_jquery_expression
1097
1098
1099 class TestRecipeBuild(WindmillTestCase):
1100@@ -50,7 +49,7 @@
1101 owner=self.chef, distroseries=self.squirrel, name=u'cake_recipe',
1102 description=u'This recipe builds a foo for disto bar, with my'
1103 ' Secret Squirrel changes.', branches=[cake_branch],
1104- daily_build_archive=self.ppa)
1105+ daily_build_archive=self.ppa, build_daily=True, is_stale=True)
1106 transaction.commit()
1107 login_person(self.chef, "chef@example.com", "test", self.client)
1108
1109@@ -74,8 +73,8 @@
1110
1111 # Ensure it shows up.
1112 client.waits.forElement(
1113- xpath = (u'//tr[contains(@class, "package-build")]/td[4]'
1114- '/a[@href="%s"]') % PPAFormatterAPI(self.ppa).url(),
1115+ jquery=u"('tr.package-build a[href$=\"%s\"]')"
1116+ % quote_jquery_expression(PPAFormatterAPI(self.ppa).url()),
1117 timeout=FOR_ELEMENT)
1118
1119 # And try the same one again.
1120@@ -85,12 +84,27 @@
1121
1122 # And check that there's an error.
1123 client.waits.forElement(
1124- xpath = (
1125- u'//div[contains(@class, "yui3-lazr-formoverlay-errors")]'
1126- '/ul/li'), timeout=FOR_ELEMENT)
1127- client.asserts.assertText(
1128- xpath = (
1129- u'//div[contains(@class, "yui3-lazr-formoverlay-errors")]'
1130- '/ul/li'),
1131+ jquery=u"('div.yui3-lazr-formoverlay-errors ul li')",
1132+ timeout=FOR_ELEMENT)
1133+
1134+ client.asserts.assertTextIn(
1135+ jquery=u"('div.yui3-lazr-formoverlay-errors ul li')[0]",
1136 validator=u'An identical build is already pending for %s %s.'
1137 % (self.ppa.distribution.name, self.squirrel.name))
1138+
1139+ def test_recipe_daily_build_request(self):
1140+ """Request a recipe build."""
1141+
1142+ client = self.client
1143+ client.open(url=canonical_url(self.recipe))
1144+ client.waits.forElement(
1145+ id=u'request-daily-build', timeout=PAGE_LOAD)
1146+
1147+ # Request a daily build.
1148+ client.click(id=u'request-daily-build')
1149+
1150+ # Ensure it shows up.
1151+ client.waits.forElement(
1152+ jquery=u"('tr.package-build a[href$=\"%s\"]')"
1153+ % quote_jquery_expression(PPAFormatterAPI(self.ppa).url()),
1154+ timeout=FOR_ELEMENT)
1155
1156=== modified file 'lib/lp/testing/__init__.py'
1157--- lib/lp/testing/__init__.py 2011-02-11 00:25:51 +0000
1158+++ lib/lp/testing/__init__.py 2011-02-23 10:25:23 +0000
1159@@ -28,6 +28,7 @@
1160 'normalize_whitespace',
1161 'oauth_access_token_for',
1162 'person_logged_in',
1163+ 'quote_jquery_expression'
1164 'record_statements',
1165 'run_with_login',
1166 'run_with_storm_debug',
1167@@ -790,6 +791,12 @@
1168 return client, obj_url
1169
1170
1171+def quote_jquery_expression(expression):
1172+ """jquery requires meta chars used in literals escaped with \\"""
1173+ return re.sub(
1174+ "([#!$%&()+,./:;?@~|^{}\\[\\]`*\\\'\\\"])", r"\\\\\1", expression)
1175+
1176+
1177 class YUIUnitTestCase(WindmillTestCase):
1178
1179 layer = None