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