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