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