Merge lp:~zyga/lava-android-test/blackbox into lp:lava-android-test
- blackbox
- Merge into trunk
Status: | Merged |
---|---|
Merged at revision: | 205 |
Proposed branch: | lp:~zyga/lava-android-test/blackbox |
Merge into: | lp:lava-android-test |
Diff against target: |
493 lines (+489/-0) 1 file modified
lava_android_test/test_definitions/blackbox.py (+489/-0) |
To merge this branch: | bzr merge lp:~zyga/lava-android-test/blackbox |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Andy Doan (community) | Approve | ||
Linaro Validation Team | Pending | ||
Review via email: mp+121299@code.launchpad.net |
Commit message
Description of the change
Integration with the blackbox tests for android.
This is _not_ a typical test. Please read the implementation and especially all the comments to understand the decisions made.
Yongqin Liu (liuyq0307) wrote : | # |
Zygmunt Krynicki (zyga) wrote : | # |
W dniu 29.08.2012 08:34, Yongqin Liu pisze:
> from this file itself, it's no problem.
> but I still feel that make it a new sub command(like run-blackbox) in the commands.py
> will make the source more readable.
> although that needs to modify more files (including lava-dispatcher) instead of only this one file.
>
> And also suggest adding a provider in the provider.py to support such tests that only need to run and get the test result.
> no need to use the parser to parse the output.
The only long term solution is to fix the API mistakes and remove all
other subcommands. There is no justification for having all those
subcommands. All of that are tests, they should have single install/run
interface.
> and maybe this is just a temporary place to integrate the blackbox test,
> since I can't see how the blackbox will be in the future, so please also ask Andy to review this MP.
OK
Thanks
ZK
Andy Doan (doanac) wrote : | # |
Seems like some of the code isn't that necessary:
debuggable_* - cool, but now that the feature works is it needed?
SuperAdb/AdbMixin - seems like you could pretty much just add a new listdir helper
However, that's nothing major and its isolated. Also, as Yongqin said, the blackbox stuff will eventually move us to something else, so lets get this in and see it work!
Zygmunt Krynicki (zyga) wrote : | # |
W dniu 29.08.2012 17:52, Andy Doan pisze:
> Review: Approve
>
> Seems like some of the code isn't that necessary:
>
> debuggable_* - cool, but now that the feature works is it needed?
No, it's not needed but it's still a cool thing (since l-a-t swallows
most exceptions) for analyzing problems. Perhaps I could just move it to
some utils.py module?
> SuperAdb/AdbMixin - seems like you could pretty much just add a new listdir helper
Yes, that behaves much like os.listdir() and has more predictable
behavior. It's just a helper that does parsing essentially :-)
> However, that's nothing major and its isolated. Also, as Yongqin said, the blackbox stuff will eventually move us to something else, so lets get this in and see it work!
I don't know how it will evolve over time. My current intent is to
ensure we can continue to harvest data from l-b with l-a-t, and in near
future with l-t. Once the rest of the framework solidifies and matures
we could simplify the interaction but I don't suspect we'll go away form
the simple usefulness of adb-managed communication. It simply works very
well and has little complexity.
Thanks
ZK
Preview Diff
1 | === added file 'lava_android_test/test_definitions/blackbox.py' |
2 | --- lava_android_test/test_definitions/blackbox.py 1970-01-01 00:00:00 +0000 |
3 | +++ lava_android_test/test_definitions/blackbox.py 2012-08-29 00:06:21 +0000 |
4 | @@ -0,0 +1,489 @@ |
5 | +# Copyright (c) 2012 Linaro Limited |
6 | + |
7 | +# Author: Zygmunt Krynicki <zygmunt.krynicki@linaro.org> |
8 | +# |
9 | +# This file is part of LAVA Android Test. |
10 | +# |
11 | +# This program is free software: you can redistribute it and/or modify |
12 | +# it under the terms of the GNU General Public License as published by |
13 | +# the Free Software Foundation, either version 3 of the License, or |
14 | +# (at your option) any later version. |
15 | +# |
16 | +# This program is distributed in the hope that it will be useful, |
17 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
18 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
19 | +# GNU General Public License for more details. |
20 | +# |
21 | +# You should have received a copy of the GNU General Public License |
22 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
23 | + |
24 | +""" |
25 | +Bridge for the black-box testing implemented by android-lava-wrapper. |
26 | + |
27 | +See: https://github.com/zyga/android-lava-wrapper |
28 | +""" |
29 | + |
30 | +import datetime |
31 | +import functools |
32 | +import logging |
33 | +import os |
34 | +import pdb |
35 | +import shutil |
36 | +import subprocess |
37 | +import tempfile |
38 | + |
39 | +from linaro_dashboard_bundle.evolution import DocumentEvolution |
40 | +from linaro_dashboard_bundle.io import DocumentIO |
41 | + |
42 | +from lava_android_test.config import get_config |
43 | + |
44 | + |
45 | +def debuggable_real(func): |
46 | + """ |
47 | + Helper for debugging functions that otherwise have their exceptions |
48 | + consumed by the caller. Any exception raised from such a function will |
49 | + trigger a pdb session when 'DEBUG_DEBUGGABLE' environment is set. |
50 | + """ |
51 | + @functools.wraps(func) |
52 | + def debuggable_decorator(*args, **kwargs): |
53 | + try: |
54 | + return func(*args, **kwargs) |
55 | + except: |
56 | + logging.exception("exception in @debuggable function") |
57 | + pdb.post_mortem() |
58 | + return debuggable_decorator |
59 | + |
60 | + |
61 | +def debuggable_noop(func): |
62 | + return func |
63 | + |
64 | + |
65 | +if os.getenv("DEBUG_DEBUGGABLE"): |
66 | + debuggable = debuggable_real |
67 | +else: |
68 | + debuggable = debuggable_noop |
69 | + |
70 | + |
71 | +class SuperAdb(object): |
72 | + """ |
73 | + Class that implements certain parts of ADB()-like API differently. |
74 | + """ |
75 | + |
76 | + def __init__(self, stock_adb): |
77 | + # Name of the adb executable with any required arguments, |
78 | + # such as -s 'serial' |
79 | + self._adb_cmd = stock_adb.adb.split() |
80 | + |
81 | + def __call__(self, command, *args, **kwargs): |
82 | + """ |
83 | + Invoke adb command. |
84 | + |
85 | + This call is somewhat special that it wraps two subprocess helper |
86 | + functions: check_call and check_output. They are called depending |
87 | + on the keyword argument 'stdout', if passed as None then output |
88 | + is _not_ saved and is instantly streamed to the stdout of the running |
89 | + program. In any other case stdout is buffered and saved, then returned |
90 | + """ |
91 | + cmd = self._adb_cmd + command |
92 | + if "stdout" in kwargs and kwargs['stdout'] is None: |
93 | + del kwargs['stdout'] |
94 | + return subprocess.check_call(cmd, *args, **kwargs) |
95 | + else: |
96 | + return subprocess.check_output(cmd, *args, **kwargs) |
97 | + |
98 | + def listdir(self, dirname): |
99 | + """ |
100 | + List directory entries on the android device. |
101 | + |
102 | + Similar to adb.listdir() as implemented in ADB() but generates |
103 | + subsequent lines instead of returning a big lump of text for the |
104 | + developer to parse. Also, instead of using 'ls' on the target it |
105 | + uses the special 'ls' command built into adb. |
106 | + |
107 | + The two special entries, . and .., are omitted |
108 | + """ |
109 | + for line in self(['ls', dirname]).splitlines(): |
110 | + # a, b and c are various pieces of stat data |
111 | + # but we don't need that here. |
112 | + a, b, c, pathname = line.split(' ', 3) |
113 | + if pathname not in ('.', '..'): |
114 | + yield pathname |
115 | + |
116 | + |
117 | +class AdbMixIn(object): |
118 | + """ |
119 | + Mix-in class that assists in setting up ADB. |
120 | + |
121 | + lava-android-test uses the setadb()/getadb() methods to pass the ADB object |
122 | + (which encapsulates connection data for the specific device we will be |
123 | + talking to). |
124 | + |
125 | + Since the ADB object has fixed API and changes there are beyond the scope |
126 | + of this test any extra stuff we want from ADB will be provided by the |
127 | + SuperAdb class. |
128 | + |
129 | + This mix-in class that has methods expected by lava-android-test and |
130 | + exposes two properties, adb and super_adb. |
131 | + """ |
132 | + |
133 | + adb = None |
134 | + |
135 | + def setadb(self, adb=None): |
136 | + if self.adb is None and adb is not None: |
137 | + self.adb = adb |
138 | + else: |
139 | + self.adb = adb |
140 | + self.super_adb = SuperAdb(adb) |
141 | + |
142 | + def getadb(self): |
143 | + return self.adb |
144 | + |
145 | + |
146 | +class Sponge(object): |
147 | + """ |
148 | + A simple namespace-like object that anyone can assign and read freely. |
149 | + |
150 | + To get some understanding of what is going on both reads and writes are |
151 | + logged. |
152 | + """ |
153 | + |
154 | + def __getattr__(self, attr): |
155 | + return super(Sponge, self).__getattr__(attr) |
156 | + |
157 | + def __setattr__(self, attr, value): |
158 | + super(Sponge, self).__setattr__(attr, value) |
159 | + |
160 | + |
161 | +class FutureFormatDetected(Exception): |
162 | + """ |
163 | + Exception raised when the code detects a new, unsupported |
164 | + format that was created after this library was written. |
165 | + |
166 | + Since formats do not have partial ordering we can only detect |
167 | + a future format when the document format is already at the "latest" |
168 | + value, as determined by DocumentEvolution.is_latest(), but the actual |
169 | + format is not known to us. |
170 | + |
171 | + Typically this won't happen often as document upgrades are not performed |
172 | + unless necessary. The only case when this may happen is where the bundle |
173 | + loaded from the device was already using a future format to begin with. |
174 | + """ |
175 | + |
176 | + def __init__(self, format): |
177 | + self.format = format |
178 | + |
179 | + def __str__(self): |
180 | + "Unsupported, future format: %s" % self.format |
181 | + |
182 | + def __repr__(self): |
183 | + return "FutureFormatDetected(%r)" % self.format |
184 | + |
185 | + |
186 | +class BlackBoxTestBridge(AdbMixIn): |
187 | + """ |
188 | + Bridge for interacting with black box tests implemented as something that |
189 | + looks like android test definition. |
190 | + """ |
191 | + |
192 | + # NOTE: none of the tests will actually carry this ID, it is simply used |
193 | + # here so that it's not a magic value. |
194 | + testname = 'blackbox' |
195 | + |
196 | + def __init__(self): |
197 | + """ |
198 | + Initialize black-box test bridge |
199 | + """ |
200 | + # The sponge object is just a requirement from the API, it is not |
201 | + # actually used by us in any way. The framework assigns a skeleton |
202 | + # test result there but we don't really need it. The Sponge object |
203 | + # is a simple 'bag' or namespace that will happily accept and record |
204 | + # any values. |
205 | + self.parser = Sponge() |
206 | + |
207 | + def install(self, install_options=None): |
208 | + """ |
209 | + "Install" blackbox on the test device. |
210 | + |
211 | + Black box tests cannot be installed, they must be pre-baked into the |
212 | + image. To conform to the 'protocol' used by lava-android-test we will |
213 | + perform a fake 'installation' of the black box tests by creating a |
214 | + directory that lava-android-test is checking for. We do that only if |
215 | + the lava-blackbox executable, which is the entry point to black box |
216 | + tests exists in the image. |
217 | + |
218 | + ..note:: |
219 | + This method is part of the lava-android-test framework API. |
220 | + """ |
221 | + if not self.adb.exists(self._blackbox_pathname): |
222 | + # Sadly lava-android-test has no exception hierarchy that we can |
223 | + # use so all problems are reported as RuntimeError |
224 | + raise RuntimeError( |
225 | + 'blackbox test cannot be "installed" as they must be built' |
226 | + ' into the image.' |
227 | + ' See https://github.com/zyga/android-lava-wrapper' |
228 | + ' for details.') |
229 | + else: |
230 | + self.adb.makedirs(self._fake_install_path) |
231 | + |
232 | + def uninstall(self): |
233 | + """ |
234 | + Conformance method to keep up with the API required by |
235 | + lava-android-test. It un-does what install() did by removing the |
236 | + _fake_install_path directory from the device. |
237 | + |
238 | + ..note:: |
239 | + This method is part of the lava-android-test framework API. |
240 | + """ |
241 | + if self.adb.exists(self._fake_install_path): |
242 | + self.adb.rmtree(self._fake_install_path) |
243 | + |
244 | + @debuggable |
245 | + def run(self, quiet=False, run_options=None): |
246 | + """ |
247 | + Run the black-box test on the target device. |
248 | + |
249 | + Use ADB to run the black-box executable on the device. Keep the results |
250 | + in the place that lava-android-test expects us to use. |
251 | + |
252 | + ..note:: |
253 | + This method is part of the lava-android-test framework API. |
254 | + """ |
255 | + # The blackbox test runner will create a directory each time it is |
256 | + # started. All of those directories will be created relative to a so |
257 | + # called spool directory. Instead of using the default spool directory |
258 | + # (which can also change) we will use the directory where |
259 | + # lava-android-test keeps all of the results. |
260 | + spool_dir = get_config().resultsdir_android |
261 | + logging.debug("Using spool directory for black-box testing: %r", spool_dir) |
262 | + stuff_before = frozenset(self.super_adb.listdir(spool_dir)) |
263 | + blackbox_command = [ |
264 | + 'shell', self._blackbox_pathname, |
265 | + '--spool', spool_dir, |
266 | + '--run-all-tests'] |
267 | + # Let's run the blackbox executable via ADB |
268 | + logging.debug("Starting black-box tests...") |
269 | + self.super_adb(blackbox_command, stdout=None) |
270 | + logging.debug("Black-box tests have finished!") |
271 | + stuff_after = frozenset(self.super_adb.listdir(spool_dir)) |
272 | + # Check what got added to the spool directory |
273 | + new_entries = stuff_after - stuff_before |
274 | + if len(new_entries) == 0: |
275 | + raise RuntimeError("Nothing got added to the spool directory") |
276 | + elif len(new_entries) > 1: |
277 | + raise RuntimeError("Multiple items added to the spool directory") |
278 | + result_id = list(new_entries)[0] |
279 | + print "The blackbox test have finished running, the result id is %r" % result_id |
280 | + return result_id |
281 | + |
282 | + def parse(self, result_id): |
283 | + """ |
284 | + UNIMPLEMENTED METHOD |
285 | + |
286 | + Sadly this method is never called as lava-android-test crashes before |
287 | + it gets to realize it is processing blackbox results and load this |
288 | + class. This crash _may_ be avoided by hiding the real results of |
289 | + blackbox and instead populating the results directory with dummy test |
290 | + results that only let LAVA figure out that blackbox is the test to |
291 | + load. Then we could monkey patch other parts and it could be |
292 | + implemented. |
293 | + |
294 | + ONCE THIS IS FIXED THE FOLLOWING DESCRIPTION SHOULD APPLY |
295 | + |
296 | + Parse and save results of previous test run. |
297 | + |
298 | + The result_id is a name of a directory on the Android device ( |
299 | + relative to the resultsdir_android configuration option). |
300 | + |
301 | + ..note:: |
302 | + This method is part of the lava-android-test framework API. |
303 | + """ |
304 | + # Sadly since the goal is integration with lava lab I don't have the |
305 | + # time to do it. In the lab we use lava-android-test run -o anyway. |
306 | + raise NotImplementedError() |
307 | + |
308 | + def _get_combined_bundle(self, result_id): |
309 | + """ |
310 | + Compute the combined bundle of a past run and return it |
311 | + """ |
312 | + config = get_config() |
313 | + temp_dir = tempfile.mkdtemp() |
314 | + remote_bundle_dir = os.path.join(config.resultsdir_android, result_id) |
315 | + try: |
316 | + self._copy_all_bundles(remote_bundle_dir, temp_dir) |
317 | + bundle = self._combine_bundles(temp_dir) |
318 | + finally: |
319 | + shutil.rmtree(temp_dir) |
320 | + return bundle |
321 | + |
322 | + # Desired format name, used in a few methods below |
323 | + _desired_format = "Dashboard Bundle Format 1.3" |
324 | + |
325 | + def _copy_all_bundles(self, android_src, host_dest): |
326 | + """ |
327 | + Use adb pull to copy all the files from android_src (android |
328 | + fileystem) to host_dest (host filesystem). |
329 | + """ |
330 | + logging.debug("Saving bundles from %s to %s", android_src, host_dest) |
331 | + for name in self.super_adb.listdir(android_src): |
332 | + logging.debug("Considering file %s", name) |
333 | + # NOTE: We employ simple filtering for '.json' files. This prevents |
334 | + # spurious JSON parsing errors if the result directory has |
335 | + # additional files of any kind. |
336 | + # |
337 | + # We _might_ want to lessen that eventually restriction but at this |
338 | + # time blackbox is really designed to be self-sufficient so there |
339 | + # is no point of additional files. |
340 | + if not name.endswith('.json'): |
341 | + continue |
342 | + remote_pathname = os.path.join(android_src, name) |
343 | + local_pathname = os.path.join(host_dest, name) |
344 | + try: |
345 | + logging.debug( |
346 | + "Copying %s to %s", remote_pathname, local_pathname) |
347 | + self.adb.pull(remote_pathname, local_pathname) |
348 | + except: |
349 | + logging.exception("Unable to copy bundle %s", name) |
350 | + |
351 | + def _combine_bundles(self, dirname): |
352 | + """ |
353 | + Combine all bundles from a previous test run into one bundle. |
354 | + |
355 | + Returns the aggregated bundle object |
356 | + |
357 | + Load, parse and validate each bundle from the specified directory and |
358 | + combine them into one larger bundle. This is somewhat tricky. Each |
359 | + bundle we coalesce may be generated by a different, separate programs |
360 | + and may, thus, use different formats. |
361 | + |
362 | + To combine them all correctly we need to take two precautions: |
363 | + 1) All bundles must be updated to a single, common format |
364 | + 2) No bundle may be upgraded beyond the latest format known |
365 | + to this code. Since the hypothetical 2.0 format may be widely |
366 | + different that we cannot reliably interpret anything beyond |
367 | + the format field. To prevent this we use the evolution API |
368 | + to carefully upgrade only to the "sentinel" format, 1.3 |
369 | + (at this time) |
370 | + """ |
371 | + # Use DocumentIO.loads() to preserve the order of entries. |
372 | + # This is a very small touch but it makes reading the results |
373 | + # far more pleasant. |
374 | + aggregated_bundle = DocumentIO.loads( |
375 | + '{\n' |
376 | + '"format": "' + self._desired_format + '",\n' |
377 | + '"test_runs": []\n' |
378 | + '}\n')[1] |
379 | + # Iterate over all files there |
380 | + for name in os.listdir(dirname): |
381 | + bundle_pathname = os.path.join(dirname, name) |
382 | + # Process bundle one by one |
383 | + try: |
384 | + format, bundle = self._load_bundle(bundle_pathname) |
385 | + self._convert_to_common_format(format, bundle) |
386 | + self._combine_with_aggregated(aggregated_bundle, bundle) |
387 | + except: |
388 | + logging.exception("Unable to process bundle %s", name) |
389 | + # Return the aggregated bundle |
390 | + return aggregated_bundle |
391 | + |
392 | + def _load_bundle(self, local_pathname): |
393 | + """ |
394 | + Load the bundle from local_pathname. |
395 | + |
396 | + There are various problems that can happen here but |
397 | + they should all be treated equally, the bundle not |
398 | + being used. This also transparently does schema validation |
399 | + so the chance of getting wrong data is lower. |
400 | + """ |
401 | + with open(local_pathname, 'rt') as stream: |
402 | + format, bundle = DocumentIO.load(stream) |
403 | + return format, bundle |
404 | + |
405 | + def _convert_to_common_format(self, format, bundle): |
406 | + """ |
407 | + Convert the bundle to the common format. |
408 | + |
409 | + This is a careful and possibly fragile process that may |
410 | + raise FutureFormatDetected exception. If that happens |
411 | + then desired_format (encoded in the function itself) must be |
412 | + changed and the code reviewed for any possible changes |
413 | + required to support the more recent format. |
414 | + """ |
415 | + while True: |
416 | + # Break conditions, encoded separately for clarity |
417 | + if format == self._desired_format: |
418 | + # This is our desired break condition, when format |
419 | + # becomes (or starts as) the desired format |
420 | + break |
421 | + if DocumentEvolution.is_latest(bundle): |
422 | + # This is a less desired break condition, if we |
423 | + # got here then the only possible explanation is |
424 | + # that some program started with format > desired_format |
425 | + # and the DocumentEvolution API is updated to understand |
426 | + # it but we are not. In that case let's raise an exception |
427 | + raise FutureFormatDetected(format) |
428 | + # As long as the document format is old keep upgrading it |
429 | + # step-by-step. Evolution is done in place |
430 | + DocumentEvolution.evolve_document(bundle, one_step=True) |
431 | + |
432 | + def _combine_with_aggregated(self, aggregated_bundle, bundle): |
433 | + """ |
434 | + Combine the bundle with the contents of aggregated_bundle. |
435 | + |
436 | + This method simply transplants all the test runs as that is what |
437 | + the bundle format was designed to be - a simple container for test |
438 | + runs. |
439 | + """ |
440 | + assert bundle["format"] == self._desired_format |
441 | + assert aggregated_bundle["format"] == self._desired_format |
442 | + aggregated_bundle["test_runs"].extend(bundle.get("test_runs", [])) |
443 | + |
444 | + @property |
445 | + def _blackbox_pathname(self): |
446 | + """ |
447 | + The path to the blackbox bridge on the device. |
448 | + """ |
449 | + return "/system/bin/lava-blackbox" |
450 | + |
451 | + @property |
452 | + def _fake_install_path(self): |
453 | + """ |
454 | + The path that we create on the android system to |
455 | + indicate that the black box test is installed. |
456 | + |
457 | + This is used by uninstall() and install() |
458 | + """ |
459 | + config = get_config() |
460 | + return os.path.join(config.installdir_android, self.testname) |
461 | + |
462 | + def _monkey_patch_lava(self): |
463 | + """ |
464 | + Monkey patch the implementation of |
465 | + lava_android_test.commands.generate_bundle |
466 | + |
467 | + This change is irreversible but given the one-off nature of |
468 | + lava-android-test this is okay. It should be safe to do this since |
469 | + LAVA will only load the blackbox test module if we explicitly request |
470 | + to run it. At that time no other tests will run in the same process. |
471 | + |
472 | + This method should not be used once lava-android-test grows a better |
473 | + API to allow us to control how bundles are generated. |
474 | + """ |
475 | + from lava_android_test import commands |
476 | + def _phony_generate_bundle(serial=None, result_id=None, |
477 | + test=None, test_id=None, attachments=[]): |
478 | + if result_id is None: |
479 | + raise NotImplementedError |
480 | + return self._get_combined_bundle(result_id) |
481 | + commands.generate_bundle = _phony_generate_bundle |
482 | + logging.warning( |
483 | + "The 'blackbox' test definition has monkey-patched the function" |
484 | + " lava_android_test.commands.generate_bundle() if you are _not_" |
485 | + " running the blackbox test or are experiencing odd problems/crashes" |
486 | + " below please look at this method first") |
487 | + |
488 | + |
489 | +# initialize the blackbox test definition object |
490 | +testobj = BlackBoxTestBridge() |
491 | + |
492 | +# Then monkey patch lava-android-test so that parse keeps working |
493 | +testobj._monkey_patch_lava() |
from this file itself, it's no problem.
but I still feel that make it a new sub command(like run-blackbox) in the commands.py
will make the source more readable.
although that needs to modify more files (including lava-dispatcher) instead of only this one file.
And also suggest adding a provider in the provider.py to support such tests that only need to run and get the test result.
no need to use the parser to parse the output.
and maybe this is just a temporary place to integrate the blackbox test,
since I can't see how the blackbox will be in the future, so please also ask Andy to review this MP.