##// END OF EJS Templates
testing: add a 'continuous' profile...
David R. MacIver -
r28259:7829d0ba default
parent child Browse files
Show More
@@ -1,565 +1,593 b''
1 1 from __future__ import print_function, absolute_import
2 2
3 3 """Fuzz testing for operations against a Mercurial repository
4 4
5 5 This uses Hypothesis's stateful testing to generate random repository
6 6 operations and test Mercurial using them, both to see if there are any
7 7 unexpected errors and to compare different versions of it."""
8 8
9 9 import os
10 10 import sys
11 11
12 12 # These tests require Hypothesis and pytz to be installed.
13 13 # Running 'pip install hypothesis pytz' will achieve that.
14 14 # Note: This won't work if you're running Python < 2.7.
15 15 try:
16 16 from hypothesis.extra.datetime import datetimes
17 17 except ImportError:
18 18 sys.stderr.write("skipped: hypothesis or pytz not installed" + os.linesep)
19 19 sys.exit(80)
20 20
21 21 # If you are running an old version of pip you may find that the enum34
22 22 # backport is not installed automatically. If so 'pip install enum34' will
23 23 # fix this problem.
24 24 try:
25 25 import enum
26 26 assert enum # Silence pyflakes
27 27 except ImportError:
28 28 sys.stderr.write("skipped: enum34 not installed" + os.linesep)
29 29 sys.exit(80)
30 30
31 31 import binascii
32 32 from contextlib import contextmanager
33 33 import errno
34 34 import pipes
35 35 import shutil
36 36 import silenttestrunner
37 37 import subprocess
38 38
39 39 from hypothesis.errors import HypothesisException
40 40 from hypothesis.stateful import (
41 41 rule, RuleBasedStateMachine, Bundle, precondition)
42 42 from hypothesis import settings, note, strategies as st
43 43 from hypothesis.configuration import set_hypothesis_home_dir
44 from hypothesis.database import ExampleDatabase
44 45
45 46 testdir = os.path.abspath(os.environ["TESTDIR"])
46 47
47 48 # We store Hypothesis examples here rather in the temporary test directory
48 49 # so that when rerunning a failing test this always results in refinding the
49 50 # previous failure. This directory is in .hgignore and should not be checked in
50 51 # but is useful to have for development.
51 52 set_hypothesis_home_dir(os.path.join(testdir, ".hypothesis"))
52 53
53 54 runtests = os.path.join(os.environ["RUNTESTDIR"], "run-tests.py")
54 55 testtmp = os.environ["TESTTMP"]
55 56 assert os.path.isdir(testtmp)
56 57
57 58 generatedtests = os.path.join(testdir, "hypothesis-generated")
58 59
59 60 try:
60 61 os.makedirs(generatedtests)
61 62 except OSError:
62 63 pass
63 64
64 65 # We write out generated .t files to a file in order to ease debugging and to
65 66 # give a starting point for turning failures Hypothesis finds into normal
66 67 # tests. In order to ensure that multiple copies of this test can be run in
67 68 # parallel we use atomic file create to ensure that we always get a unique
68 69 # name.
69 70 file_index = 0
70 71 while True:
71 72 file_index += 1
72 73 savefile = os.path.join(generatedtests, "test-generated-%d.t" % (
73 74 file_index,
74 75 ))
75 76 try:
76 77 os.close(os.open(savefile, os.O_CREAT | os.O_EXCL | os.O_WRONLY))
77 78 break
78 79 except OSError as e:
79 80 if e.errno != errno.EEXIST:
80 81 raise
81 82 assert os.path.exists(savefile)
82 83
83 84 hgrc = os.path.join(".hg", "hgrc")
84 85
85 86 filecharacters = (
86 87 "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
87 88 "[]^_`;=@{}~ !#$%&'()+,-"
88 89 )
89 90
90 91 files = st.text(filecharacters, min_size=1).map(lambda x: x.strip()).filter(
91 92 bool).map(lambda s: s.encode('ascii'))
92 93
93 94 safetext = st.text(st.characters(
94 95 min_codepoint=1, max_codepoint=127,
95 96 blacklist_categories=('Cc', 'Cs')), min_size=1).map(
96 97 lambda s: s.encode('utf-8')
97 98 )
98 99
99 100 @contextmanager
100 101 def acceptableerrors(*args):
101 102 """Sometimes we know an operation we're about to perform might fail, and
102 103 we're OK with some of the failures. In those cases this may be used as a
103 104 context manager and will swallow expected failures, as identified by
104 105 substrings of the error message Mercurial emits."""
105 106 try:
106 107 yield
107 108 except subprocess.CalledProcessError as e:
108 109 if not any(a in e.output for a in args):
109 110 note(e.output)
110 111 raise
111 112
112 113 reponames = st.text("abcdefghijklmnopqrstuvwxyz01234556789", min_size=1).map(
113 114 lambda s: s.encode('ascii')
114 115 )
115 116
116 117 class verifyingstatemachine(RuleBasedStateMachine):
117 118 """This defines the set of acceptable operations on a Mercurial repository
118 119 using Hypothesis's RuleBasedStateMachine.
119 120
120 121 The general concept is that we manage multiple repositories inside a
121 122 repos/ directory in our temporary test location. Some of these are freshly
122 123 inited, some are clones of the others. Our current working directory is
123 124 always inside one of these repositories while the tests are running.
124 125
125 126 Hypothesis then performs a series of operations against these repositories,
126 127 including hg commands, generating contents and editing the .hgrc file.
127 128 If these operations fail in unexpected ways or behave differently in
128 129 different configurations of Mercurial, the test will fail and a minimized
129 130 .t test file will be written to the hypothesis-generated directory to
130 131 exhibit that failure.
131 132
132 133 Operations are defined as methods with @rule() decorators. See the
133 134 Hypothesis documentation at
134 135 http://hypothesis.readthedocs.org/en/release/stateful.html for more
135 136 details."""
136 137
137 138 # A bundle is a reusable collection of previously generated data which may
138 139 # be provided as arguments to future operations.
139 140 repos = Bundle('repos')
140 141 paths = Bundle('paths')
141 142 contents = Bundle('contents')
142 143 branches = Bundle('branches')
143 144 committimes = Bundle('committimes')
144 145
145 146 def __init__(self):
146 147 super(verifyingstatemachine, self).__init__()
147 148 self.repodir = os.path.join(testtmp, "repos")
148 149 if os.path.exists(self.repodir):
149 150 shutil.rmtree(self.repodir)
150 151 os.chdir(testtmp)
151 152 self.log = []
152 153 self.failed = False
153 154
154 155 self.mkdirp("repos")
155 156 self.cd("repos")
156 157 self.mkdirp("repo1")
157 158 self.cd("repo1")
158 159 self.hg("init")
159 160 self.extensions = {}
160 161 self.all_extensions = set()
161 162 self.non_skippable_extensions = set()
162 163
163 164 def teardown(self):
164 165 """On teardown we clean up after ourselves as usual, but we also
165 166 do some additional testing: We generate a .t file based on our test
166 167 run using run-test.py -i to get the correct output.
167 168
168 169 We then test it in a number of other configurations, verifying that
169 170 each passes the same test."""
170 171 super(verifyingstatemachine, self).teardown()
171 172 try:
172 173 shutil.rmtree(self.repodir)
173 174 except OSError:
174 175 pass
175 176 ttest = os.linesep.join(" " + l for l in self.log)
176 177 os.chdir(testtmp)
177 178 path = os.path.join(testtmp, "test-generated.t")
178 179 with open(path, 'w') as o:
179 180 o.write(ttest + os.linesep)
180 181 with open(os.devnull, "w") as devnull:
181 182 rewriter = subprocess.Popen(
182 183 [runtests, "--local", "-i", path], stdin=subprocess.PIPE,
183 184 stdout=devnull, stderr=devnull,
184 185 )
185 186 rewriter.communicate("yes")
186 187 with open(path, 'r') as i:
187 188 ttest = i.read()
188 189
189 190 e = None
190 191 if not self.failed:
191 192 try:
192 193 for ext in (
193 194 self.all_extensions - self.non_skippable_extensions
194 195 ):
195 196 try:
196 197 os.environ["SKIP_EXTENSION"] = ext
197 198 output = subprocess.check_output([
198 199 runtests, path, "--local",
199 200 ], stderr=subprocess.STDOUT)
200 201 assert "Ran 1 test" in output, output
201 202 finally:
202 203 del os.environ["SKIP_EXTENSION"]
203 204 output = subprocess.check_output([
204 205 runtests, path, "--local", "--pure"
205 206 ], stderr=subprocess.STDOUT)
206 207 assert "Ran 1 test" in output, output
207 208 except subprocess.CalledProcessError as e:
208 209 note(e.output)
209 210 finally:
210 211 os.unlink(path)
211 212 try:
212 213 os.unlink(path + ".err")
213 214 except OSError:
214 215 pass
215 216 if self.failed or e is not None:
216 217 with open(savefile, "wb") as o:
217 218 o.write(ttest)
218 219 if e is not None:
219 220 raise e
220 221
221 222 def execute_step(self, step):
222 223 try:
223 224 return super(verifyingstatemachine, self).execute_step(step)
224 225 except (HypothesisException, KeyboardInterrupt):
225 226 raise
226 227 except Exception:
227 228 self.failed = True
228 229 raise
229 230
230 231 # Section: Basic commands.
231 232 def mkdirp(self, path):
232 233 if os.path.exists(path):
233 234 return
234 235 self.log.append(
235 236 "$ mkdir -p -- %s" % (pipes.quote(os.path.relpath(path)),))
236 237 os.makedirs(path)
237 238
238 239 def cd(self, path):
239 240 path = os.path.relpath(path)
240 241 if path == ".":
241 242 return
242 243 os.chdir(path)
243 244 self.log.append("$ cd -- %s" % (pipes.quote(path),))
244 245
245 246 def hg(self, *args):
246 247 self.command("hg", *args)
247 248
248 249 def command(self, *args):
249 250 self.log.append("$ " + ' '.join(map(pipes.quote, args)))
250 251 subprocess.check_output(args, stderr=subprocess.STDOUT)
251 252
252 253 # Section: Set up basic data
253 254 # This section has no side effects but generates data that we will want
254 255 # to use later.
255 256 @rule(
256 257 target=paths,
257 258 source=st.lists(files, min_size=1).map(lambda l: os.path.join(*l)))
258 259 def genpath(self, source):
259 260 return source
260 261
261 262 @rule(
262 263 target=committimes,
263 264 when=datetimes(min_year=1970, max_year=2038) | st.none())
264 265 def gentime(self, when):
265 266 return when
266 267
267 268 @rule(
268 269 target=contents,
269 270 content=st.one_of(
270 271 st.binary(),
271 272 st.text().map(lambda x: x.encode('utf-8'))
272 273 ))
273 274 def gencontent(self, content):
274 275 return content
275 276
276 277 @rule(
277 278 target=branches,
278 279 name=safetext,
279 280 )
280 281 def genbranch(self, name):
281 282 return name
282 283
283 284 @rule(target=paths, source=paths)
284 285 def lowerpath(self, source):
285 286 return source.lower()
286 287
287 288 @rule(target=paths, source=paths)
288 289 def upperpath(self, source):
289 290 return source.upper()
290 291
291 292 # Section: Basic path operations
292 293 @rule(path=paths, content=contents)
293 294 def writecontent(self, path, content):
294 295 self.unadded_changes = True
295 296 if os.path.isdir(path):
296 297 return
297 298 parent = os.path.dirname(path)
298 299 if parent:
299 300 try:
300 301 self.mkdirp(parent)
301 302 except OSError:
302 303 # It may be the case that there is a regular file that has
303 304 # previously been created that has the same name as an ancestor
304 305 # of the current path. This will cause mkdirp to fail with this
305 306 # error. We just turn this into a no-op in that case.
306 307 return
307 308 with open(path, 'wb') as o:
308 309 o.write(content)
309 310 self.log.append((
310 311 "$ python -c 'import binascii; "
311 312 "print(binascii.unhexlify(\"%s\"))' > %s") % (
312 313 binascii.hexlify(content),
313 314 pipes.quote(path),
314 315 ))
315 316
316 317 @rule(path=paths)
317 318 def addpath(self, path):
318 319 if os.path.exists(path):
319 320 self.hg("add", "--", path)
320 321
321 322 @rule(path=paths)
322 323 def forgetpath(self, path):
323 324 if os.path.exists(path):
324 325 with acceptableerrors(
325 326 "file is already untracked",
326 327 ):
327 328 self.hg("forget", "--", path)
328 329
329 330 @rule(s=st.none() | st.integers(0, 100))
330 331 def addremove(self, s):
331 332 args = ["addremove"]
332 333 if s is not None:
333 334 args.extend(["-s", str(s)])
334 335 self.hg(*args)
335 336
336 337 @rule(path=paths)
337 338 def removepath(self, path):
338 339 if os.path.exists(path):
339 340 with acceptableerrors(
340 341 'file is untracked',
341 342 'file has been marked for add',
342 343 'file is modified',
343 344 ):
344 345 self.hg("remove", "--", path)
345 346
346 347 @rule(
347 348 message=safetext,
348 349 amend=st.booleans(),
349 350 when=committimes,
350 351 addremove=st.booleans(),
351 352 secret=st.booleans(),
352 353 close_branch=st.booleans(),
353 354 )
354 355 def maybecommit(
355 356 self, message, amend, when, addremove, secret, close_branch
356 357 ):
357 358 command = ["commit"]
358 359 errors = ["nothing changed"]
359 360 if amend:
360 361 errors.append("cannot amend public changesets")
361 362 command.append("--amend")
362 363 command.append("-m" + pipes.quote(message))
363 364 if secret:
364 365 command.append("--secret")
365 366 if close_branch:
366 367 command.append("--close-branch")
367 368 errors.append("can only close branch heads")
368 369 if addremove:
369 370 command.append("--addremove")
370 371 if when is not None:
371 372 if when.year == 1970:
372 373 errors.append('negative date value')
373 374 if when.year == 2038:
374 375 errors.append('exceeds 32 bits')
375 376 command.append("--date=%s" % (
376 377 when.strftime('%Y-%m-%d %H:%M:%S %z'),))
377 378
378 379 with acceptableerrors(*errors):
379 380 self.hg(*command)
380 381
381 382 # Section: Repository management
382 383 @property
383 384 def currentrepo(self):
384 385 return os.path.basename(os.getcwd())
385 386
386 387 @rule(
387 388 target=repos,
388 389 source=repos,
389 390 name=reponames,
390 391 )
391 392 def clone(self, source, name):
392 393 if not os.path.exists(os.path.join("..", name)):
393 394 self.cd("..")
394 395 self.hg("clone", source, name)
395 396 self.cd(name)
396 397 return name
397 398
398 399 @rule(
399 400 target=repos,
400 401 name=reponames,
401 402 )
402 403 def fresh(self, name):
403 404 if not os.path.exists(os.path.join("..", name)):
404 405 self.cd("..")
405 406 self.mkdirp(name)
406 407 self.cd(name)
407 408 self.hg("init")
408 409 return name
409 410
410 411 @rule(name=repos)
411 412 def switch(self, name):
412 413 self.cd(os.path.join("..", name))
413 414 assert self.currentrepo == name
414 415 assert os.path.exists(".hg")
415 416
416 417 @rule(target=repos)
417 418 def origin(self):
418 419 return "repo1"
419 420
420 421 @rule()
421 422 def pull(self, repo=repos):
422 423 with acceptableerrors(
423 424 "repository default not found",
424 425 "repository is unrelated",
425 426 ):
426 427 self.hg("pull")
427 428
428 429 @rule(newbranch=st.booleans())
429 430 def push(self, newbranch):
430 431 with acceptableerrors(
431 432 "default repository not configured",
432 433 "no changes found",
433 434 ):
434 435 if newbranch:
435 436 self.hg("push", "--new-branch")
436 437 else:
437 438 with acceptableerrors(
438 439 "creates new branches"
439 440 ):
440 441 self.hg("push")
441 442
442 443 # Section: Simple side effect free "check" operations
443 444 @rule()
444 445 def log(self):
445 446 self.hg("log")
446 447
447 448 @rule()
448 449 def verify(self):
449 450 self.hg("verify")
450 451
451 452 @rule()
452 453 def diff(self):
453 454 self.hg("diff", "--nodates")
454 455
455 456 @rule()
456 457 def status(self):
457 458 self.hg("status")
458 459
459 460 @rule()
460 461 def export(self):
461 462 self.hg("export")
462 463
463 464 # Section: Branch management
464 465 @rule()
465 466 def checkbranch(self):
466 467 self.hg("branch")
467 468
468 469 @rule(branch=branches)
469 470 def switchbranch(self, branch):
470 471 with acceptableerrors(
471 472 'cannot use an integer as a name',
472 473 'cannot be used in a name',
473 474 'a branch of the same name already exists',
474 475 'is reserved',
475 476 ):
476 477 self.hg("branch", "--", branch)
477 478
478 479 @rule(branch=branches, clean=st.booleans())
479 480 def update(self, branch, clean):
480 481 with acceptableerrors(
481 482 'unknown revision',
482 483 'parse error',
483 484 ):
484 485 if clean:
485 486 self.hg("update", "-C", "--", branch)
486 487 else:
487 488 self.hg("update", "--", branch)
488 489
489 490 # Section: Extension management
490 491 def hasextension(self, extension):
491 492 repo = self.currentrepo
492 493 return repo in self.extensions and extension in self.extensions[repo]
493 494
494 495 def commandused(self, extension):
495 496 assert extension in self.all_extensions
496 497 self.non_skippable_extensions.add(extension)
497 498
498 499 @rule(extension=st.sampled_from((
499 500 'shelve', 'mq', 'blackbox',
500 501 )))
501 502 def addextension(self, extension):
502 503 self.all_extensions.add(extension)
503 504 extensions = self.extensions.setdefault(self.currentrepo, set())
504 505 if extension in extensions:
505 506 return
506 507 extensions.add(extension)
507 508 if not os.path.exists(hgrc):
508 509 self.command("touch", hgrc)
509 510 with open(hgrc, 'a') as o:
510 511 line = "[extensions]\n%s=\n" % (extension,)
511 512 o.write(line)
512 513 for l in line.splitlines():
513 514 self.log.append((
514 515 '$ if test "$SKIP_EXTENSION" != "%s" ; '
515 516 'then echo %r >> %s; fi') % (
516 517 extension, l, hgrc,))
517 518
518 519 # Section: Commands from the shelve extension
519 520 @rule()
520 521 @precondition(lambda self: self.hasextension("shelve"))
521 522 def shelve(self):
522 523 self.commandused("shelve")
523 524 with acceptableerrors("nothing changed"):
524 525 self.hg("shelve")
525 526
526 527 @rule()
527 528 @precondition(lambda self: self.hasextension("shelve"))
528 529 def unshelve(self):
529 530 self.commandused("shelve")
530 531 with acceptableerrors("no shelved changes to apply"):
531 532 self.hg("unshelve")
532 533
534 class writeonlydatabase(ExampleDatabase):
535 def __init__(self, underlying):
536 super(ExampleDatabase, self).__init__()
537 self.underlying = underlying
538
539 def fetch(self, key):
540 return ()
541
542 def save(self, key, value):
543 self.underlying.save(key, value)
544
545 def delete(self, key, value):
546 self.underlying.delete(key, value)
547
548 def close(self):
549 self.underlying.close()
550
533 551 settings.register_profile(
534 552 'default', settings(
535 553 timeout=300,
536 554 stateful_step_count=50,
537 555 max_examples=10,
538 556 )
539 557 )
540 558
541 559 settings.register_profile(
542 560 'fast', settings(
543 561 timeout=10,
544 562 stateful_step_count=20,
545 563 max_examples=5,
546 564 min_satisfying_examples=1,
547 565 max_shrinks=0,
548 566 )
549 567 )
550 568
569 settings.register_profile(
570 'continuous', settings(
571 timeout=-1,
572 stateful_step_count=1000,
573 max_examples=10 ** 8,
574 max_iterations=10 ** 8,
575 database=writeonlydatabase(settings.default.database)
576 )
577 )
578
551 579 settings.load_profile(os.getenv('HYPOTHESIS_PROFILE', 'default'))
552 580
553 581 verifyingtest = verifyingstatemachine.TestCase
554 582
555 583 verifyingtest.settings = settings.default
556 584
557 585 if __name__ == '__main__':
558 586 try:
559 587 silenttestrunner.main(__name__)
560 588 finally:
561 589 # So as to prevent proliferation of useless test files, if we never
562 590 # actually wrote a failing test we clean up after ourselves and delete
563 591 # the file for doing so that we owned.
564 592 if os.path.exists(savefile) and os.path.getsize(savefile) == 0:
565 593 os.unlink(savefile)
General Comments 0
You need to be logged in to leave comments. Login now