summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRichard Maw <richard.maw@codethink.co.uk>2014-02-11 14:27:17 +0000
committerRichard Maw <richard.maw@codethink.co.uk>2014-02-11 15:19:25 +0000
commit62fa7e08f76a5b6bb8410add49c40656b3e73acd (patch)
treeddb17bfaa101ac3a65336d7d71ed0f08bdd5b487
parent1b80990bd5e58091dc2da50adda747a10922b29e (diff)
parentc43e94039a017e60743e49822d932facef54da93 (diff)
downloadcmdtest-62fa7e08f76a5b6bb8410add49c40656b3e73acd.tar.gz
Merge branch 'baserock/richardmaw/S10275/factor-out-modules-v3' into baserock/morph
Update the version of cmdtest used in Baserock and include the patches to move functionality into yarnlib. Reviewed-by: Lars Wirzenius
-rw-r--r--NEWS24
-rw-r--r--README.yarn20
-rwxr-xr-xcmdtest7
-rwxr-xr-xyarn277
-rw-r--r--yarn-doc/index.mdwn284
-rwxr-xr-xyarn.tests/env.script5
-rwxr-xr-xyarn.tests/two-no-thens.exit1
-rwxr-xr-xyarn.tests/two-no-thens.script23
-rwxr-xr-xyarn.tests/two-no-thens.stderr3
-rw-r--r--yarnlib/__init__.py12
-rw-r--r--yarnlib/elements.py7
-rw-r--r--yarnlib/scenario_runner.py202
-rw-r--r--yarnlib/scenario_runner_tests.py129
-rw-r--r--yarnlib/scenario_step_connector.py108
-rw-r--r--yarnlib/scenario_step_connector_tests.py95
-rw-r--r--yarnlib/scenario_validator.py82
-rw-r--r--yarnlib/scenario_validator_tests.py68
-rw-r--r--yarnlib/shell_libraries.py45
-rw-r--r--yarnlib/shell_libraries_tests.py59
19 files changed, 1254 insertions, 197 deletions
diff --git a/NEWS b/NEWS
index 47fbd88..5c99718 100644
--- a/NEWS
+++ b/NEWS
@@ -3,6 +3,30 @@ NEWS for cmdtest
This file summarizes changes between releases of cmdtest.
+Version 0.11, released UNRELEASED
+---------------------------------
+
+For yarn:
+
+* Report number of scenarios skipped due to an ASSUMING step failing.
+ * Fix the error message for reporting scenarios without THEN steps
+ to include the names of those scenarios only, rather than all
+ scenarios. Patch by Pete Fotheringham.
+
+* Yarn now sets `$HOME` to a directory in `$DATADIR`, and creates
+ that directory. This means test suites can assume `$HOME` exists,
+ but don't use the user's real home directory, which is important for
+ test environment hygiene.
+
+* New option `--allow-missing-steps` to allow running a test suite
+ with some steps missing. The scenarios with missing steps will be
+ skipped. Suggested by Pete Fotheringham.
+
+For cmdtest:
+
+* The `--test` (`-t`) option should now work again. Thank you
+ to Kalle Valo for prodding me.
+
Version 0.10, released 2013-10-05
---------------------------------
diff --git a/README.yarn b/README.yarn
index 16f7522..5f1c7a2 100644
--- a/README.yarn
+++ b/README.yarn
@@ -1,6 +1,26 @@
README for yarn, a scenario testing tool
========================================
+Installation
+------------
+
+* You need Python 2. Yarn's dependencies do not work with Python 3.
+ (Yet.)
+* Install ttystatus: <http://git.liw.fi/cgi-bin/cgit/cgit.cgi/ttystatus>
+* Install cliapp: <http://git.liw.fi/cgi-bin/cgit/cgit.cgi/cliapp>
+* Install Python-Markdown:
+ <http://github.com/waylan/Python-Markdown.git>
+* You can install them from source, or using your operating system's
+ package manager (if they've been packaged for your operating system;
+ Debian has them: `python-ttystatus`, `python-cliapp`,
+ `python-markdown`).
+* Similarly, install yarn from source or with the package manager
+ (in Debian: `cmdtest`).
+* Installation from source requires cloning the git repository and
+ running `python setup.py install`, perhaps with additional options
+ (see `--help`) to specify installation location, etc. Read also the
+ installation documentation of the libraries.
+
Introduction
------------
diff --git a/cmdtest b/cmdtest
index b8c6efa..d7f329e 100755
--- a/cmdtest
+++ b/cmdtest
@@ -120,6 +120,13 @@ class CommandTester(cliapp.Application):
for test in self.settings['test']:
matches = glob.glob(os.path.join(dirname, test + '.*'))
filenames += [os.path.basename(x) for x in matches]
+
+ basenames = ['setup_once', 'setup', 'teardown', 'teardown_once']
+ for basename in basenames:
+ filename = os.path.join(dirname, basename)
+ if os.path.exists(filename):
+ filenames.append(basename)
+
return filenames
def setup_tempdir(self):
diff --git a/yarn b/yarn
index f4abcdc..6dc09c5 100755
--- a/yarn
+++ b/yarn
@@ -1,5 +1,5 @@
#!/usr/bin/python
-# Copyright 2013 Lars Wirzenius
+# Copyright 2013-2014 Lars Wirzenius
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@@ -79,6 +79,11 @@ class YarnRunner(cliapp.Application):
['timings'],
'report wall clock time for each scenario and step')
+ self.settings.boolean(
+ ['allow-missing-steps'],
+ 'allow scenarios to reference steps that do not exist, '
+ 'by warning about them, but otherwise ignoring the scenarios')
+
def info(self, msg):
if self.settings['verbose']:
logging.info(msg)
@@ -109,32 +114,50 @@ class YarnRunner(cliapp.Application):
self.ts = ttystatus.TerminalStatus(period=0.001)
if not self.settings['quiet'] and not self.settings['verbose']:
self.ts.format(
- '%ElapsedTime() %Index(scenario,scenarios): '
+ '%ElapsedTime() %Index(current_step,all_steps): '
'%String(scenario_name): '
- 'step %Index(step,steps): %String(step_name)')
+ '%String(step_name)')
+
+ if self.settings['tempdir']:
+ self.tempdir = os.path.abspath(self.settings['tempdir'])
+ if not os.path.exists(self.tempdir):
+ os.mkdir(self.tempdir)
+ else:
+ self.tempdir = tempfile.mkdtemp()
scenarios, implementations = self.parse_scenarios(args)
- self.check_there_are_scenarios(scenarios)
- self.check_for_duplicate_scenario_names(scenarios)
- self.check_for_thens(scenarios)
- self.connect_implementations(scenarios, implementations)
+ sv = yarnlib.ScenarioValidator(scenarios)
+ sv.validate_all()
+ scenarios = self.connect_implementations(scenarios, implementations)
shell_prelude = self.load_shell_libraries()
- self.ts['scenarios'] = scenarios
- self.ts['num_scenarios'] = len(scenarios)
self.info('Found %d scenarios' % len(scenarios))
+ all_steps = []
+ for scenario in scenarios:
+ all_steps.extend(scenario.steps)
+ self.ts['all_steps'] = all_steps
+
self.scenarios_run = 0
+ self.skipped_for_assuming = 0
self.steps_run = 0
self.timings = []
+ scenario_runner = yarnlib.ScenarioRunner(shell_prelude, os.getcwd(),
+ self.parse_env(),
+ pre_step_cb=self.pre_step,
+ post_step_cb=self.post_step)
+
start_time = time.time()
failed_scenarios = []
for scenario in self.select_scenarios(scenarios):
- if not self.run_scenario(scenario, shell_prelude):
+ if not self.run_scenario(scenario_runner, scenario):
failed_scenarios.append(scenario)
duration = time.time() - start_time
+ if not self.settings['snapshot']:
+ shutil.rmtree(self.tempdir)
+
if not self.settings['quiet']:
self.ts.clear()
self.ts.finish()
@@ -149,6 +172,9 @@ class YarnRunner(cliapp.Application):
'(%d total steps), '
'in %.1f seconds' %
(self.scenarios_run, self.steps_run, duration))
+ if self.skipped_for_assuming:
+ print ('Scenarios SKIPPED due to ASSUMING step failing: %d'
+ % self.skipped_for_assuming)
if self.settings['timings']:
self.report_timings()
@@ -166,76 +192,28 @@ class YarnRunner(cliapp.Application):
return block_parser.scenarios, block_parser.implementations
- def check_there_are_scenarios(self, scenarios):
- if not scenarios:
- raise cliapp.AppException(
- 'There are no scenarios; must have at least one.')
-
- def check_for_duplicate_scenario_names(self, scenarios):
- counts = collections.Counter()
- for s in scenarios:
- counts[s.name] += 1
-
- duplicates = [name for name in counts if counts[name] > 1]
- if duplicates:
- duplist = ''.join(' %s\n' % name for name in duplicates)
- raise cliapp.AppException(
- 'There are scenarios with duplicate names:\n%s' % duplist)
-
- def check_for_thens(self, scenarios):
- no_thens = []
- for scenario in scenarios:
- for step in scenario.steps:
- if step.what == 'THEN':
- break
- else:
- no_thens.append(scenario)
-
- if no_thens:
- raise cliapp.AppException(
- 'Some scenarios have no THENs:\n%s' %
- ''.join(' "%s"\n' % s.name for s in scenarios))
-
def connect_implementations(self, scenarios, implementations):
- for scenario in scenarios:
- for step in scenario.steps:
- self.connect_implementation(scenario, step, implementations)
-
- def connect_implementation(self, scenario, step, implementations):
- matching = [i for i in implementations
- if step.what == i.what and
- self.implements_matches_step(i, step)]
-
- if len(matching) == 0:
- raise cliapp.AppException(
- 'Scenario "%s", step "%s %s" has no matching '
- 'implementation' %
- (scenario.name, step.what, step.text))
- if len(matching) > 1:
- s = '\n'.join(
- 'IMPLEMENTS %s %s' % (i.what, i.regexp)
- for i in matching)
- raise cliapp.AppException(
- 'Scenario "%s", step "%s %s" has more than one '
- 'matching implementations:\n%s' %
- (scenario.name, step.what, step.text, s))
+ if self.settings['allow-missing-steps']:
+ def warn_missing(scenario, step):
+ self.warning(
+ 'Scenario %s has missing step %s %s' %
+ (scenario.name, step.what, step.text))
+ return True
+ step_connector = yarnlib.ScenarioStepConnector(
+ implementations, missing_step_cb=warn_missing)
+ else:
+ step_connector = yarnlib.ScenarioStepConnector(implementations)
- assert step.implementation is None
- step.implementation = matching[0]
+ return step_connector.connect_implementations(scenarios)
def load_shell_libraries(self):
if not self.settings['shell-library']:
self.info('No shell libraries defined')
return ''
- libs = []
- for filename in self.settings['shell-library']:
- self.info('Loading shell library %s' % filename)
- with open(filename) as f:
- text = f.read()
- libs.append('# Loaded from %s\n\n%s\n\n' % (filename, text))
-
- return ''.join(libs)
+ return yarnlib.load_shell_libraries(
+ self.settings['shell-library'],
+ pre_read_cb=lambda fn: self.info('Loading shell library %s' % fn))
def select_scenarios(self, scenarios):
@@ -256,133 +234,60 @@ class YarnRunner(cliapp.Application):
return scenarios
- def run_scenario(self, scenario, shell_prelude):
+ def run_scenario(self, scenario_runner, scenario):
self.start_scenario_timing(scenario.name)
started = time.time()
self.info('Running scenario %s' % scenario.name)
- self.ts['scenario'] = scenario
self.ts['scenario_name'] = scenario.name
- self.ts['steps'] = scenario.steps
self.scenarios_run += 1
if self.settings['no-act']:
self.info('Pretending everything went OK')
+ for step in scenario.steps:
+ self.ts['current_step'] = step
self.remember_scenario_timing(time.time() - started)
return True
- if self.settings['tempdir']:
- tempdir = os.path.abspath(self.settings['tempdir'])
- if not os.path.exists(tempdir):
- os.mkdir(tempdir)
- else:
- tempdir = tempfile.mkdtemp()
-
- os.mkdir(self.scenario_dir(tempdir, scenario))
- datadir = self.datadir(tempdir, scenario)
+ os.mkdir(self.scenario_dir(self.tempdir, scenario))
+ datadir = self.datadir(self.tempdir, scenario)
os.mkdir(datadir)
self.info('DATADIR is %s' % datadir)
+ homedir = self.homedir(datadir)
+ os.mkdir(homedir)
+ self.info('HOME for tests is %s' % homedir)
- assuming = [s for s in scenario.steps if s.what == 'ASSUMING']
- cleanup = [s for s in scenario.steps if s.what == 'FINALLY']
- normal = [s for s in scenario.steps if s not in assuming + cleanup]
-
- ok = True
- step_number = 0
-
- for step in assuming:
- exit = self.run_step(datadir, scenario, step, shell_prelude, False)
- step_number += 1
- self.snapshot_datadir(
- tempdir, datadir, scenario, step_number, step)
- if exit != 0:
- self.ts.notify(
- 'Skipping "%s" because "%s %s" failed' %
- (scenario.name, step.what, step.text))
- break
- else:
- for step in normal:
- exit = self.run_step(
- datadir, scenario, step, shell_prelude, True)
- step_number += 1
- self.snapshot_datadir(
- tempdir, datadir, scenario, step_number, step)
- if exit != 0:
- ok = False
- break
-
- for step in cleanup:
- exit = self.run_step(
- datadir, scenario, step, shell_prelude, True)
- step_number += 1
- self.snapshot_datadir(
- tempdir, datadir, scenario, step_number, step)
- if exit != 0:
- ok = False
- break
-
- if not self.settings['snapshot']:
- shutil.rmtree(tempdir)
+ ok = scenario_runner.run_scenario(scenario, datadir, homedir)
self.remember_scenario_timing(time.time() - started)
return ok
- def clean_env(self):
- '''Return a clean environment for running tests.'''
-
- whitelisted = [
- 'PATH',
- ]
-
- hardcoded = {
- 'TERM': 'dumb',
- 'SHELL': '/bin/sh',
- 'LC_ALL': 'C',
- 'USER': 'tomjon',
- 'USERNAME': 'tomjon',
- 'LOGNAME': 'tomjon',
- 'HOME': '/this/path/does/not/exist',
- }
-
- env = {}
-
- for key in whitelisted:
- if key in os.environ:
- env[key] = os.environ[key]
-
- for key in hardcoded:
- env[key] = hardcoded[key]
+ def homedir(self, datadir):
+ return os.path.join(datadir, 'HOME')
+ def parse_env(self):
for option_arg in self.settings['env']:
if '=' not in option_arg:
raise cliapp.AppException(
'--env argument must contain "=" '
'to separate environment variable name and value')
key, value = option_arg.split('=', 1)
- env[key] = value
+ yield key, value
- return env
-
- def run_step(self, datadir, scenario, step, shell_prelude, report_error):
+ def pre_step(self, step, **ignored):
started = time.time()
self.info('Running step "%s %s"' % (step.what, step.text))
- self.ts['step'] = step
+ self.ts['current_step'] = step
self.ts['step_name'] = '%s %s' % (step.what, step.text)
self.steps_run += 1
- m = self.implements_matches_step(step.implementation, step)
- assert m is not None
- env = self.clean_env()
- env['DATADIR'] = datadir
- env['SRCDIR'] = os.getcwd()
- for i, match in enumerate(m.groups('')):
- env['MATCH_%d' % (i+1)] = match
+ return (started,)
- shell_script = '%s\n\n%s\n' % (
- shell_prelude, step.implementation.shell)
- exit, stdout, stderr = cliapp.runcmd_unchecked(
- ['sh', '-xeuc', shell_script], env=env)
+ def post_step(self, scenario, step, step_number, step_env,
+ exit, stdout, stderr, pre_step_userdata):
+ stopped = time.time()
+ (started,) = pre_step_userdata
logging.debug('Exit code: %d' % exit)
if stdout:
@@ -393,20 +298,24 @@ class YarnRunner(cliapp.Application):
logging.debug('Standard error:\n%s' % self.indent(stderr))
else:
logging.debug('Standard error: empty')
-
- if exit != 0 and report_error:
- self.error(
- 'ERROR: In scenario "%s"\nstep "%s %s" failed,\n'
- 'with exit code %d:\n'
- 'Standard output from shell command:\n%s'
- 'Standard error from shell command:\n%s' %
- (scenario.name, step.what, step.text, exit,
- self.indent(stdout), self.indent(stderr)))
-
+ if exit != 0:
+ if step.what == 'ASSUMING':
+ self.ts.notify(
+ 'Skipping "%s" because "%s %s" failed' %
+ (scenario.name, step.what, step.text))
+ self.skipped_for_assuming += 1
+ else:
+ self.error(
+ 'ERROR: In scenario "%s"\nstep "%s %s" failed,\n'
+ 'with exit code %d:\n'
+ 'Standard output from shell command:\n%s'
+ 'Standard error from shell command:\n%s' %
+ (scenario.name, step.what, step.text, exit,
+ self.indent(stdout), self.indent(stderr)))
self.remember_step_timing(
- '%s %s' % (step.what, step.text), time.time() - started)
-
- return exit
+ '%s %s' % (step.what, step.text), stopped - started)
+ self.snapshot_datadir(self.tempdir, step_env['DATADIR'],
+ scenario, step_number, step)
def scenario_dir(self, tempdir, scenario):
return os.path.join(tempdir, self.nice(scenario.name))
@@ -439,18 +348,6 @@ class YarnRunner(cliapp.Application):
nice = ''.join(nice)
return nice
- def implements_matches_step(self, implements, step):
- '''Return re.Match if implements matches the step.
-
- Otherwise, return None.
-
- '''
-
- m = re.match(implements.regexp, step.text, re.I)
- if m and m.end() != len(step.text):
- return None
- return m
-
def indent(self, s):
return ''.join(' %s\n' % line for line in s.splitlines())
diff --git a/yarn-doc/index.mdwn b/yarn-doc/index.mdwn
new file mode 100644
index 0000000..d63dee0
--- /dev/null
+++ b/yarn-doc/index.mdwn
@@ -0,0 +1,284 @@
+% Yarn manual
+
+Yarn
+====
+
+FIXME: This will become a manual for writing test suites in yarn. It
+is currently not yet written.
+
+Mission
+-------
+This manual will provide all the information needed by Yarn users to enable
+them to use Yarn effectively in their development projects.
+
+The information will be
+
+* easy to find
+* easy to navigate
+* easy to use
+
+The information will include details of
+
+* how to perform certain tasks
+* why things are done in particular ways
+
+Document Status
+---------------
+
+### What's Done
+
+* Outline
+* Mission
+* this Document Status section
+
+### What's New
+
+* Writing Scenarios
+ * Test Language Specification
+* Introduction
+ * Skeleton
+ * What is `yarn`?
+
+
+### What's Next
+
+* Introduction
+ * Remaining sections
+* `yarn`'s command line
+* How to embed `yarn` in Markdown
+
+Introduction
+------------
+
+### What is `yarn`?
+
+`yarn` is a scenario testing tool: you write a scenario describing how a
+user uses your software and what should happen, and express, using
+very lightweight syntax, the scenario in such a way that it can be tested
+automatically. The scenario has a simple, but strict structure:
+
+ SCENARIO name of scenario
+ GIVEN some setup for the test
+ WHEN thing that is to be tested happens
+ THEN the post-conditions must be true
+
+As an example, consider a very short test scenario for verifying that
+a backup program works, at least for one simple case.
+
+ SCENARIO basic backup and restore
+ GIVEN some live data in a directory
+ AND an empty backup repository
+ WHEN a backup is made
+ THEN the data can be restored
+
+(Note the addition of AND: you can have multiple GIVEN, WHEN, and
+THEN statements. The AND keyword makes the text be more readable.)
+
+
+### Who is `yarn` for?
+
+### Who are the test suites written in `yarn` for?
+
+### What kinds of testing is `yarn` for?
+
+### Why `yarn` instead of other tools?
+
+### Why not cmdtest?
+
+Writing Scenarios
+-----------------
+
+Scenarios are meant to be written in mostly human readable language.
+However, they are not free form text. In addition to the GIVEN/WHEN/THEN
+structure, the text for each of the steps needs a computer-executable
+implementation. This is done by using IMPLEMENTS. The backup scenario
+from above might be implemented as follows:
+
+ IMPLEMENTS GIVEN some live data in a directory
+ rm -rf "$DATADIR/data"
+ mkdir "$DATADIR/data"
+ echo foo > "$DATADIR/data/foo"
+
+ IMPLEMENTS GIVEN an empty backup repository
+ rm -rf "$DATADIR/repo"
+ mkdir "$DATADIR/repo"
+
+ IMPLEMENTS WHEN a backup is made
+ backup-program -r "$DATADIR/repo" "$DATADIR/data"
+
+ IMPLEMENTS THEN the data can be restored
+ mkdir "$DATADIR/restored"
+ restore-program -r "$DATADIR/repo" "$DATADIR/restored"
+ diff -rq "$DATADIR/data" "$DATADIR/restored"
+
+Each "IMPLEMENT GIVEN" (or WHEN, THEN) is followed by a regular
+expression on the same line, and then a shell script that gets executed
+to implement any step that matches the regular expression. The
+implementation can extract data from the match as well: for example,
+the regular expression might allow a file size to be specified.
+
+The above example seems a bit silly, of course: why go to the effort
+to obfuscate the various steps? The answer is that the various steps,
+implemented using IMPLEMENTS, can be combined in many ways, to test
+different aspects of the program being tested. In effect, the IMPLEMENTS
+sections provide a vocabulary which the scenario writer can use to
+express a variety of usefully different scenarios, which together
+test all the aspects of the software that need to be tested.
+
+Moreover, by making the step descriptions be human language
+text, matched by regular expressions, most of the test can
+hopefully be written, and understood, by non-programmers. Someone
+who understands what a program should do, could write tests
+to verify its behaviour. The implementations of the various
+steps need to be implemented by a programmer, but given a
+well-designed set of steps, with enough flexibility in their
+implementation, that quite a good test suite can be written.
+
+### Test Language Specification
+
+A test document is written in [Markdown][markdown], with block
+quoted code blocks being interpreted specially. Each block
+must follow the syntax defined here.
+
+* Every step in a scenario is one line, and starts with a keyword.
+
+* Each implementation (IMPLEMENTS) starts as a new block, and
+ continues until there is a block that starts with another
+ keyword.
+
+The following keywords are defined.
+
+* **SCENARIO** starts a new scenario. The rest of the line is the name of
+ the scenario. The name is used for documentation and reporting
+ purposes only and has no semantic meaning. SCENARIO MUST be the
+ first keyword in a scenario, with the exception of IMPLEMENTS.
+ The set of documents passed in a test run may define any number of
+ scenarios between them, but there must be at least one or it is a
+ test failure. The IMPLEMENTS sections are shared between the
+ documents and scenarios.
+
+* **ASSUMING** defines a condition for the scenario. The rest of the
+ line is "matched text", which gets implemented by an
+ IMPLEMENTS section. If the code executed by the implementation
+ fails, the scenario is skipped.
+
+* **GIVEN** prepares the world for the test to run. If
+ the implementation fails, the scenario fails.
+
+* **WHEN** makes the change to the world that is to be tested.
+ If the code fails, the scenario fails.
+
+* **THEN** verifies that the changes made by the GIVEN steps
+ did the right thing. If the code fails, the scenario fails.
+
+* **FINALLY** specifies how to clean up after a scenario. If the code
+ fails, the scenario fails. All FINALLY blocks get run either when
+ encountered in the scenario flow, or at the end of the scenario,
+ regardless of whether the scenario is failing or not.
+
+* **AND** acts as ASSUMING, GIVEN, WHEN, THEN, or FINALLY: whichever
+ was used last. It must not be used unless the previous step was
+ one of those, or another AND.
+
+* **IMPLEMENTS** is followed by one of ASSUMING, GIVEN, WHEN, or THEN,
+ and a PCRE regular expression, all on one line, and then further
+ lines of shell commands until the end of the block quoted code
+ block. Markdown is unclear whether an empty line (no characters,
+ not even whitespace) between two block quoted code blocks starts a
+ new one or not, so we resolve the ambiguity by specifiying that a
+ code block directly following a code block is a continuation unless
+ it starts with one of the scenario testing keywords.
+
+ The shell commands get parenthesised parts of the match of the
+ regular expression as environment variables (`$MATCH_1` etc). For
+ example, if the regexp is "a (\d+) byte file", then `$MATCH_1` gets
+ set to the number matched by `\d+`.
+
+ The test runner creates a temporary directory, whose name is
+ given to the shell code in the `DATADIR` environment variable.
+
+ The test runner sets the `SRCDIR` environment variable to the
+ path to the directory it was invoked from (by convention, the
+ root of the source tree of the project).
+
+ The test runner removes all other environment variables, except
+ `TERM`, `USER`, `USERNAME`, `LOGNAME`, `HOME`, and `PATH`. It also
+ forces `SHELL` set to `/bin/sh`, and `LC_ALL` set to `C`, in order
+ to have as clean an environment as possible for tests to run in.
+
+ The shell commands get invoked with `/bin/sh -eu`, and need to
+ be written accordingly. Be careful about commands that return a
+ non-zero exit code. There will eventually be a library of shell
+ functions supplied which allow handling the testing of non-zero
+ exit codes cleanly. In addition functions for handling stdout and
+ stderr will be provided.
+
+ The code block of an IMPLEMENTS block fails if the shell
+ invocation exits with a non-zero exit code. Output to stderr is
+ not an indication of failure. Any output to stdout or stderr may
+ or may not be shown to the user.
+
+Semantics:
+
+* The name of each scenario (given with SCENARIO) must be unique.
+* All names of scenarios and steps will be normalised before use
+ (whitespace collapse, leading and trailing whitespace
+* Every ASSUMING, GIVEN, WHEN, THEN, FINALLY must be matched by
+ exactly one IMPLEMENTS. The test runner checks this before running
+ any code.
+* Every IMPLEMENTS may match any number of ASSUMING, GIVEN, WHEN,
+ THEN, or FINALLY. The test runner may warn if an IMPLEMENTS is unused.
+* If ASSUMING fails, that scenario is skipped, and any FINALLY steps
+ are not run.
+
+
+
+Outline
+-------
+
+* Introduction
+ - what is yarn?
+ - who is yarn for?
+ - who are the test suites written in yarn for?
+ - what kinds of testing is yarn for?
+ - why yarn instead of other tools?
+ - why not cmdtest?
+ - NOT installation instructions
+* Examples
+ - a test suite for "hello world"
+ - make the files available so people can try things for themselves
+ - a few simple scenarios
+* The yarn testing language
+ - Markdown with blockquotes for the executable code
+ - SCENARIO + the step-wise keywords
+ - IMPLEMENTS sections
+* Running yarn
+ - command line syntax
+ - examples of various ways to run yarn in different scenarios:
+ - how to run just one scenario
+ - how to run yarn under cron or jenkins
+ - formatting a test suite in yarn with pandoc
+* Best practices
+ - this chapter will describe best practices for writing test suites
+ with yarn
+ - how to structure the files: what to put in each *.yarn file, e.g.,
+ where should IMPLEMENTS go
+ - how to write test suites that make it easy to debug things when a
+ test case fails
+ - good phrasing guidelines for yarn scenario names and step names
+ - what things are good to keep visible to the reader, what are
+ better hidden inside impementations of steps, with examples from
+ real projects using yarn
+ - guidelines for well-defined steps that are easy to understand and
+ easy to implement
+ - anti-patterns: things that are good to avoid
+ - make tests fast
+ - make test code be obviously correct; make test code be the best
+ code
+ - when is it OK to skip scenarios?
+* Case studies
+ - this chapter will discuss ways to use yarn in things that are not
+ just "run this program and examine the output"
+ - start a daemon in the background, kill it at the end of a scenario
+ - how to use a really heavy-weight thing in test suites (e.g., start
+ a database server for all scenarios to share)
diff --git a/yarn.tests/env.script b/yarn.tests/env.script
index d14a21a..d24172d 100755
--- a/yarn.tests/env.script
+++ b/yarn.tests/env.script
@@ -7,6 +7,7 @@ cat << 'EOF' > "$DATADIR/env.yarn"
THEN DATADIR is set
AND SRCDIR is set
AND NOTSET is not set
+ AND HOME is prefixed by DATADIR
IMPLEMENTS THEN (\S+) is set
env
@@ -14,6 +15,10 @@ cat << 'EOF' > "$DATADIR/env.yarn"
IMPLEMENTS THEN (\S+) is not set
! env | grep "^$MATCH_1="
+
+ IMPLEMENTS THEN (\S+) is prefixed by (\S+)
+ x=$(env | sed -n "/^$MATCH_2=/s///p")
+ env | grep "^$MATCH_1=$x/"
EOF
NOTSET=foo ./run-yarn "$DATADIR/env.yarn"
diff --git a/yarn.tests/two-no-thens.exit b/yarn.tests/two-no-thens.exit
new file mode 100755
index 0000000..d00491f
--- /dev/null
+++ b/yarn.tests/two-no-thens.exit
@@ -0,0 +1 @@
+1
diff --git a/yarn.tests/two-no-thens.script b/yarn.tests/two-no-thens.script
new file mode 100755
index 0000000..a6a67fd
--- /dev/null
+++ b/yarn.tests/two-no-thens.script
@@ -0,0 +1,23 @@
+#!/bin/sh
+
+set -eu
+
+
+cat <<EOF > "$DATADIR/1.yarn"
+ SCENARIO valid scenario
+ GIVEN statement 1
+ THEN statement 2
+
+ SCENARIO invalid scenario
+ GIVEN statement 1
+
+ IMPLEMENTS GIVEN statement (\d)
+ true
+
+ IMPLEMENTS THEN statement (\d)
+ true
+
+
+EOF
+
+./run-yarn "$DATADIR/1.yarn"
diff --git a/yarn.tests/two-no-thens.stderr b/yarn.tests/two-no-thens.stderr
new file mode 100755
index 0000000..4599b30
--- /dev/null
+++ b/yarn.tests/two-no-thens.stderr
@@ -0,0 +1,3 @@
+ERROR: Some scenarios have no THENs:
+ "invalid scenario"
+
diff --git a/yarnlib/__init__.py b/yarnlib/__init__.py
index 88bf46f..515082e 100644
--- a/yarnlib/__init__.py
+++ b/yarnlib/__init__.py
@@ -1,4 +1,4 @@
-# Copyright 2013 Lars Wirzenius
+# Copyright 2013-2014 Lars Wirzenius
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@@ -19,3 +19,13 @@
from mdparser import MarkdownParser
from elements import Scenario, ScenarioStep, Implementation
from block_parser import BlockParser, BlockError
+from scenario_validator import (NoScenariosError, DuplicateScenariosError,
+ NoThensError, ScenarioValidator)
+from scenario_step_connector import (implements_matches_step,
+ ScenarioStepConnector,
+ StepNotImplementedError,
+ StepMultipleImplementationsError)
+from scenario_runner import ScenarioRunner
+
+
+from shell_libraries import load_shell_libraries
diff --git a/yarnlib/elements.py b/yarnlib/elements.py
index 9eeb1b7..f6b6f76 100644
--- a/yarnlib/elements.py
+++ b/yarnlib/elements.py
@@ -1,4 +1,4 @@
-# Copyright 2013 Lars Wirzenius
+# Copyright 2013-2014 Lars Wirzenius
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
@@ -44,8 +44,3 @@ class Implementation(object):
self.regexp = regexp
self.shell = shell
- def execute(self):
- exit, out, err = cliapp.runcmd_unchecked(
- ['sh', '-c', 'set -eu\n' + self.shell])
- return exit
-
diff --git a/yarnlib/scenario_runner.py b/yarnlib/scenario_runner.py
new file mode 100644
index 0000000..e0cdf62
--- /dev/null
+++ b/yarnlib/scenario_runner.py
@@ -0,0 +1,202 @@
+# Copyright 2014 Lars Wirzenius and Codethink Limited
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+# =*= License: GPL-3+ =*=
+
+
+import cliapp
+import os
+
+import yarnlib
+
+
+def default_pre_step(scenario, step, step_number, step_env):
+ '''Default callback run before each step in ScenarioRunner.
+
+ Parameters:
+
+ * `scenario`: The yarnlib.Scenario that is about to be run.
+ * `step`: The yarnlib.ScenarioStep that is about to be run.
+ * `step_number`: Per-scenario counter for the step that is about to be run,
+ starting from 1.
+ * `step_env`: Environment that will be used for this step.
+ * `return`: Any returned values are passed to `post_step_cb`.
+
+ All parameters are passed as keyword-arguments, so the order of
+ parameters is not important, and unused arguments can be ignored by
+ putting **kwargs in the parameter definition.
+
+ '''
+ pass
+
+
+def default_post_step(scenario, step, step_number, step_env,
+ exit, stdout, stderr, pre_step_userdata):
+ '''Default callback run after each step in ScenarioRunner.
+
+ Parameters:
+
+ * `scenario`: The yarnlib.Scenario that has just been run.
+ * `step`: The yarnlib.ScenarioStep that has just been run.
+ * `step_number`: Per-scenario counter for the step that is about to be run,
+ starting from 1.
+ * `step_env`: Environment that was used for this step.
+ * `exit`: Return code of the step that was run.
+ * `stdout`: Standard output of the step that was just run.
+ * `stderr`: Standard error of the step that was just run.
+ * `pre_step_userdata`: Return value from `pre_step_cb`.
+
+ All parameters are passed as keyword-arguments, so the order of
+ parameters is not important, and unused arguments can be ignored by
+ putting **kwargs in the parameter definition.
+
+ '''
+ pass
+
+
+# Arguably this could just be the run_scenario method with all the
+# constructor's parameters passed in, but reeks of poor design with that
+# many parameters.
+# However, decoupling the ScenarioRunner from the scenarios allows the
+# same scenarios to be run against different projects that aim to satisfy
+# the same requirements, or multiple yarn suites on the same project,
+# if the project aims to satisfy multiple sets of requirements.
+# While this use is far less likely than having 1 yarn suite per project,
+# it's a useful metric for deciding how to split the arguments between the
+# constructor and the run_scenario method.
+class ScenarioRunner(object):
+ '''Sets up an environment to run scenarios on a Project.
+
+ Parameters:
+
+ * `shell_prelude`: Prefix to all shell scripts run, e.g. shell libraries.
+ * `srcdir`: Path to the root of the source tree.
+ This is available as the $SRCDIR environment variable
+ and is the directory the scenarios are run from.
+ * `extra_env`: dict, or iterable of pairs of variables to add to
+ the clean environment provided for every command.
+ * `pre_step_cb`: Callback run before each step in a scenario.
+ See default_pre_step for an explanation of its signature.
+ Its return value is passed to `post_step_cb`.
+ * `post_step_cb`: Callback run after each step in a scenario.
+ See default_post_step for an explanation of its signature.
+ * `cmdrunner`: Function used to run step commands in place of
+ `cliapp.runcmd_unchecked`, intended to be replaceable
+ by unit tests.
+
+ '''
+
+
+ def __init__(self, shell_prelude, srcdir, extra_env=(),
+ pre_step_cb=default_pre_step, post_step_cb=default_post_step,
+ cmdrunner=cliapp.runcmd_unchecked):
+ self.shell_prelude = shell_prelude
+ self.srcdir = srcdir
+ self.env = self.clean_env(extra_env, SRCDIR=srcdir)
+ self.pre_step_cb = pre_step_cb
+ self.post_step_cb = post_step_cb
+ self.cmdrunner = cmdrunner
+
+ def run_scenario(self, scenario, datadir, homedir):
+ assuming = [s for s in scenario.steps if s.what == 'ASSUMING']
+ cleanup = [s for s in scenario.steps if s.what == 'FINALLY']
+ normal = [s for s in scenario.steps if s not in assuming + cleanup]
+
+ ok = True
+ step_number = 1
+
+ scenario_env = dict(self.env)
+ scenario_env['HOME'] = homedir
+ scenario_env['DATADIR'] = datadir
+
+ for step in assuming:
+ exit = self.run_step(scenario, step, scenario_env, step_number)
+ step_number += 1
+ if exit != 0:
+ break
+ else:
+ for step in normal:
+ exit = self.run_step(scenario, step, scenario_env, step_number)
+ step_number += 1
+ if exit != 0:
+ ok = False
+ break
+
+ for step in cleanup:
+ exit = self.run_step(scenario, step, scenario_env, step_number)
+ step_number += 1
+ if exit != 0:
+ ok = False
+ break
+
+ return ok
+
+ def run_step(self, scenario, step, scenario_env, step_number):
+ m = yarnlib.implements_matches_step(step.implementation, step)
+ assert m is not None
+ step_env = dict(scenario_env)
+ for i, match in enumerate(m.groups('')):
+ step_env['MATCH_%d' % (i+1)] = match
+
+ # All parameters passed as keyword-arguments, so that the callback
+ # may declare parameters in any order, and ignore any parameters
+ # by specifying **kwargs
+ pre_step_userdata = self.pre_step_cb(scenario=scenario, step=step,
+ step_number=step_number,
+ step_env=step_env)
+
+ shell_script = '%s\n\n%s\n' % (
+ self.shell_prelude, step.implementation.shell)
+ exit, stdout, stderr = self.cmdrunner(
+ ['sh', '-xeuc', shell_script], env=step_env, cwd=self.srcdir)
+
+ # All parameters passed as keyword-arguments, so that the callback
+ # may declare parameters in any order, and ignore any parameters
+ # by specifying **kwargs
+ self.post_step_cb(scenario=scenario, step=step,
+ step_number=step_number, step_env=step_env,
+ exit=exit, stdout=stdout, stderr=stderr,
+ pre_step_userdata=pre_step_userdata)
+
+ return exit
+
+ @staticmethod
+ def clean_env(extra_env, **kwarg_env):
+ '''Return a clean environment for running tests.'''
+
+ whitelisted = [
+ 'PATH',
+ ]
+
+ hardcoded = {
+ 'TERM': 'dumb',
+ 'SHELL': '/bin/sh',
+ 'LC_ALL': 'C',
+ 'USER': 'tomjon',
+ 'USERNAME': 'tomjon',
+ 'LOGNAME': 'tomjon',
+ }
+
+ env = {}
+
+ for key in whitelisted:
+ if key in os.environ:
+ env[key] = os.environ[key]
+
+ env.update(hardcoded)
+ env.update(extra_env)
+ env.update(kwarg_env)
+
+ return env
diff --git a/yarnlib/scenario_runner_tests.py b/yarnlib/scenario_runner_tests.py
new file mode 100644
index 0000000..cda6397
--- /dev/null
+++ b/yarnlib/scenario_runner_tests.py
@@ -0,0 +1,129 @@
+# Copyright 2014 Codethink Limited
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+# =*= License: GPL-3+ =*=
+
+
+import itertools
+import unittest
+
+import yarnlib
+
+
+class ScenarioRunnerEnvironmentTests(unittest.TestCase):
+
+ def setUp(self):
+ self.cmdlog = []
+ self.scenario = yarnlib.Scenario('foo')
+ step = yarnlib.ScenarioStep('THEN', 'foo bar')
+ step.implementation = yarnlib.Implementation('THEN', r'foo (\S+)',
+ 'echo foo $MATCH_1')
+ self.scenario.steps = [step]
+
+ def fake_cmdrunner(self, cmd, env=None, cwd=None):
+ self.cmdlog.append((cmd, env, cwd))
+ return 0, '', ''
+
+ def test_command_env(self):
+ sr = yarnlib.ScenarioRunner('', '/tmp', {'FOO': 'bar'},
+ cmdrunner=self.fake_cmdrunner)
+ sr.run_scenario(self.scenario, '/tmp/datadir', '/tmp/home')
+ cmd, cmd_env, cmd_cwd = self.cmdlog[0]
+ self.assertIn('FOO', cmd_env)
+ self.assertEqual(cmd_env['FOO'], 'bar')
+ self.assertEqual(cmd_env['SRCDIR'], '/tmp')
+ self.assertEqual(cmd_env['DATADIR'], '/tmp/datadir')
+ self.assertEqual(cmd_env['HOME'], '/tmp/home')
+ self.assertEqual(cmd_env['MATCH_1'], 'bar')
+ self.assertEqual('/tmp', cmd_cwd)
+
+ def test_command_args(self):
+ prelude = 'TESTVAR=foo\n'
+ impl_shell = self.scenario.steps[0].implementation.shell
+ sr = yarnlib.ScenarioRunner(prelude, '/tmp',
+ cmdrunner=self.fake_cmdrunner)
+ sr.run_scenario(self.scenario, '/tmp/datadir', '/tmp/home')
+ shell, opts, command = self.cmdlog[0][0]
+ self.assertTrue(shell.endswith('sh'))
+ self.assertTrue(command.startswith(prelude))
+ self.assertIn(impl_shell, command)
+
+
+class ScenarioRunnerFlowTests(unittest.TestCase):
+
+ def setUp(self):
+ self.cmdlog = []
+ self.scenario_runner = (
+ yarnlib.ScenarioRunner('', 'srcdir',
+ cmdrunner=self.fake_cmdrunner))
+ for verb, cmd in itertools.product(('ASSUMING', 'THEN', 'FINALLY'),
+ ('true', 'false')):
+ step = yarnlib.ScenarioStep(verb, cmd)
+ step.implementation = yarnlib.Implementation(verb, cmd, cmd)
+ setattr(self, '%s_%s' % (verb.lower(), cmd), step)
+
+ def fake_cmdrunner(self, cmd, *args, **kwargs):
+ self.cmdlog.append(cmd)
+ if 'false' in cmd[-1]:
+ return 1, '', ''
+ return 0, '', ''
+
+ def run_scenario(self, *steps):
+ scenario = yarnlib.Scenario('foo')
+ scenario.steps = steps
+ return self.scenario_runner.run_scenario(scenario, 'data', 'home')
+
+ def test_assuming_skips_remaining(self):
+ ok = self.run_scenario(
+ self.assuming_false,
+ self.then_true,
+ self.finally_true,
+ )
+ self.assertTrue(ok)
+ self.assertEqual(len(self.cmdlog), 1)
+
+ def test_cleanup_run_on_success(self):
+ ok = self.run_scenario(
+ self.assuming_true,
+ self.then_true,
+ self.finally_true,
+ )
+ self.assertTrue(ok)
+ self.assertEqual(len(self.cmdlog), 3)
+
+ def test_cleanup_run_on_failure(self):
+ ok = self.run_scenario(
+ self.then_false,
+ self.finally_true,
+ )
+ self.assertFalse(ok)
+ self.assertEqual(len(self.cmdlog), 2)
+
+ def test_skip_steps_after_failure(self):
+ ok = self.run_scenario(
+ self.then_false,
+ self.then_true,
+ )
+ self.assertFalse(ok)
+ self.assertEqual(len(self.cmdlog), 1)
+
+ def test_cleanup_failure(self):
+ ok = self.run_scenario(
+ self.then_true,
+ self.finally_false,
+ self.finally_true,
+ )
+ self.assertFalse(ok)
+ self.assertEqual(len(self.cmdlog), 2)
diff --git a/yarnlib/scenario_step_connector.py b/yarnlib/scenario_step_connector.py
new file mode 100644
index 0000000..3e0712f
--- /dev/null
+++ b/yarnlib/scenario_step_connector.py
@@ -0,0 +1,108 @@
+# Copyright 2014 Lars Wirzenius and Codethink Limited
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+# =*= License: GPL-3+ =*=
+
+
+import cliapp
+import re
+
+
+def implements_matches_step(implements, step):
+ '''Return re.Match if implements matches the step.
+
+ Otherwise, return None.
+
+ '''
+
+ m = re.match(implements.regexp, step.text, re.I)
+ if m and m.end() != len(step.text):
+ return None
+ return m
+
+
+class StepNotImplementedError(cliapp.AppException):
+
+ def __init__(self, scenario, step):
+ cliapp.AppException.__init__(
+ self, 'Scenario "%s", step "%s %s" has no matching '
+ 'implementation' %
+ (scenario.name, step.what, step.text))
+
+
+class StepMultipleImplementationsError(cliapp.AppException):
+
+ def __init__(self, scenario, step, matching):
+ s = '\n'.join(
+ 'IMPLEMENTS %s %s' % (i.what, i.regexp)
+ for i in matching)
+ cliapp.AppException.__init__(
+ self, 'Scenario "%s", step "%s %s" has more than one '
+ 'matching implementations:\n%s' %
+ (scenario.name, step.what, step.text, s))
+
+
+class ScenarioStepConnector(object):
+ '''Connect scenario steps to their implementations.'''
+
+ def __init__(self, implementations, missing_step_cb=lambda sc, st: None):
+ self.implementations = implementations
+ self.missing_step_cb = missing_step_cb
+
+ def connect_implementations(self, scenarios):
+ '''Connect scenario steps, returning implemented scenarios.
+
+ For each scenario given, connect known implementations to
+ the steps.
+
+ This returns only scenarios that were fully implemented.
+ Unless ``missing_step_cb`` is provided and returns true for all
+ missing steps, an exception is raised instead.
+
+ '''
+ implemented_scenarios = []
+ for scenario in scenarios:
+ missing_step = False
+ for step in scenario.steps:
+ self.connect_implementation(scenario, step)
+ if step.implementation is None:
+ missing_step = True
+ if not missing_step:
+ implemented_scenarios.append(scenario)
+ return implemented_scenarios
+
+ def connect_implementation(self, scenario, step):
+ '''Connect the step of a scenario to its implementation.
+
+ Always raises an exception if there are multiple matching
+ implementations.
+
+ Raises an exception if there are no matching implementations
+ unless ``missing_step_cb`` returns true.
+
+ '''
+ matching = [i for i in self.implementations
+ if step.what == i.what and
+ implements_matches_step(i, step)]
+
+ if len(matching) == 0:
+ if self.missing_step_cb(scenario, step):
+ return
+ raise StepNotImplementedError(scenario, step)
+ if len(matching) > 1:
+ raise StepMultipleImplementationsError(scenario, step, matching)
+
+ assert step.implementation is None
+ step.implementation = matching[0]
diff --git a/yarnlib/scenario_step_connector_tests.py b/yarnlib/scenario_step_connector_tests.py
new file mode 100644
index 0000000..e93d904
--- /dev/null
+++ b/yarnlib/scenario_step_connector_tests.py
@@ -0,0 +1,95 @@
+# Copyright 2014 Codethink Limited
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+# =*= License: GPL-3+ =*=
+
+
+import unittest
+
+import yarnlib
+
+
+class ImplementsMatchesTests(unittest.TestCase):
+
+ def test_match(self):
+ i = yarnlib.Implementation('THEN', r'foo', 'echo foo\n')
+ step = yarnlib.ScenarioStep('THEN', 'foo')
+ self.assertTrue(yarnlib.implements_matches_step(i, step))
+
+ def test_no_match(self):
+ i = yarnlib.Implementation('THEN', r'foo', 'echo foo\n')
+ step = yarnlib.ScenarioStep('THEN', 'bar')
+ self.assertIs(yarnlib.implements_matches_step(i, step), None)
+
+ def test_not_match_all(self):
+ i = yarnlib.Implementation('THEN', r'foo', 'echo foo\n')
+ step = yarnlib.ScenarioStep('THEN', 'foo bar')
+ self.assertIs(yarnlib.implements_matches_step(i, step), None)
+
+
+class ScenarioStepConnectorTests(unittest.TestCase):
+
+ def setUp(self):
+ pass
+
+ def test_all_implemented(self):
+ impl = yarnlib.Implementation('THEN', r'foo', 'echo foo\n')
+ scenario = yarnlib.Scenario('foo')
+ scenario.steps = [
+ yarnlib.ScenarioStep('THEN', 'foo')
+ ]
+ ssc = yarnlib.ScenarioStepConnector([impl])
+ scenarios = ssc.connect_implementations([scenario])
+ self.assertIs(scenarios[0].steps[0].implementation, impl)
+
+ def test_multiple_impls(self):
+ ssc = yarnlib.ScenarioStepConnector([
+ yarnlib.Implementation('THEN', r'foo \S+', 'echo foo\n'),
+ yarnlib.Implementation('THEN', r'\S+ bar', 'echo bar\n'),
+ ])
+ scenario = yarnlib.Scenario('foo bar')
+ scenario.steps = [
+ yarnlib.ScenarioStep('THEN', 'foo bar')
+ ]
+ with self.assertRaises(yarnlib.StepMultipleImplementationsError):
+ scenarios = ssc.connect_implementations([scenario])
+
+ def test_noimpl_fail(self):
+ ssc = yarnlib.ScenarioStepConnector([
+ yarnlib.Implementation('THEN', r'foo', 'echo foo\n'),
+ ])
+ scenarios = []
+ for scenario_name, step_text in [('foo', 'foo'), ('bar', 'bar')]:
+ scenario = yarnlib.Scenario(scenario_name)
+ scenario.steps = [
+ yarnlib.ScenarioStep('THEN', step_text),
+ ]
+ scenarios.append(scenario)
+ with self.assertRaises(yarnlib.StepNotImplementedError):
+ scenarios = ssc.connect_implementations(scenarios)
+
+ def test_noimpl_ignore(self):
+ ssc = yarnlib.ScenarioStepConnector([
+ yarnlib.Implementation('THEN', r'foo', 'echo foo\n'),
+ ], lambda *args: True)
+ all_scenarios = []
+ for scenario_name, step_text in [('foo', 'foo'), ('bar', 'bar')]:
+ scenario = yarnlib.Scenario(scenario_name)
+ scenario.steps = [
+ yarnlib.ScenarioStep('THEN', step_text),
+ ]
+ all_scenarios.append(scenario)
+ implemented_scenarios = ssc.connect_implementations(all_scenarios)
+ self.assertTrue(implemented_scenarios)
diff --git a/yarnlib/scenario_validator.py b/yarnlib/scenario_validator.py
new file mode 100644
index 0000000..59c86e7
--- /dev/null
+++ b/yarnlib/scenario_validator.py
@@ -0,0 +1,82 @@
+# Copyright 2014 Lars Wirzenius and Codethink Limited
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+# =*= License: GPL-3+ =*=
+
+
+import cliapp
+import collections
+
+
+class NoScenariosError(cliapp.AppException):
+
+ def __init__(self):
+ cliapp.AppException.__init__(
+ self, 'There are no scenarios; must have at least one.')
+
+
+class DuplicateScenariosError(cliapp.AppException):
+
+ def __init__(self, duplicates):
+ duplist = ''.join(' %s\n' % name for name in duplicates)
+ cliapp.AppException.__init__(
+ self, 'There are scenarios with duplicate names:\n%s' % duplist)
+
+
+class NoThensError(cliapp.AppException):
+
+ def __init__(self, thenless):
+ cliapp.AppException.__init__(
+ self, 'Some scenarios have no THENs:\n%s' %
+ ''.join(' "%s"\n' % s.name for s in thenless))
+
+
+class ScenarioValidator(object):
+
+ def __init__(self, scenarios):
+ self.scenarios = scenarios
+
+ def validate_all(self): # pragma: no cover
+ '''Convenience method to run all checks.'''
+ self.check_there_are_scenarios()
+ self.check_for_duplicate_scenario_names()
+ self.check_for_thens()
+
+ def check_there_are_scenarios(self):
+ '''Check whether any scenarios were found.'''
+ if not self.scenarios:
+ raise NoScenariosError()
+
+ def check_for_duplicate_scenario_names(self):
+ '''Check whether multiple scenarios were found sharing a name.'''
+ counts = collections.Counter()
+ for s in self.scenarios:
+ counts[s.name] += 1
+
+ duplicates = [name for name in counts if counts[name] > 1]
+ if duplicates:
+ raise DuplicateScenariosError(duplicates)
+
+ def check_for_thens(self):
+ no_thens = []
+ for scenario in self.scenarios:
+ for step in scenario.steps:
+ if step.what == 'THEN':
+ break
+ else:
+ no_thens.append(scenario)
+
+ if no_thens:
+ raise NoThensError(no_thens)
diff --git a/yarnlib/scenario_validator_tests.py b/yarnlib/scenario_validator_tests.py
new file mode 100644
index 0000000..1e482a5
--- /dev/null
+++ b/yarnlib/scenario_validator_tests.py
@@ -0,0 +1,68 @@
+# Copyright 2014 Codethink Limited
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+# =*= License: GPL-3+ =*=
+
+
+import unittest
+
+import yarnlib
+
+
+class ScenarioValidatorTests(unittest.TestCase):
+
+ def setUp(self):
+ pass
+
+ def test_check_scenarios_fail(self):
+ sv = yarnlib.ScenarioValidator([])
+ with self.assertRaises(yarnlib.NoScenariosError):
+ sv.check_there_are_scenarios()
+
+ def test_check_scenarios_pass(self):
+ sv = yarnlib.ScenarioValidator([
+ yarnlib.Scenario('foo')
+ ])
+ sv.check_there_are_scenarios()
+
+ def test_check_duplicate_names_fail(self):
+ sv = yarnlib.ScenarioValidator([
+ yarnlib.Scenario('foo'),
+ yarnlib.Scenario('foo'),
+ ])
+ with self.assertRaises(yarnlib.DuplicateScenariosError):
+ sv.check_for_duplicate_scenario_names()
+
+ def test_check_duplicate_names_pass(self):
+ sv = yarnlib.ScenarioValidator([
+ yarnlib.Scenario('foo'),
+ yarnlib.Scenario('bar'),
+ ])
+ sv.check_for_duplicate_scenario_names()
+
+ def test_check_for_thens_fail(self):
+ sv = yarnlib.ScenarioValidator([
+ yarnlib.Scenario('foo')
+ ])
+ with self.assertRaises(yarnlib.NoThensError):
+ sv.check_for_thens()
+
+ def test_check_for_thens_pass(self):
+ scenario = yarnlib.Scenario('foo')
+ scenario.steps = [
+ yarnlib.ScenarioStep('THEN', 'badger stoat')
+ ]
+ sv = yarnlib.ScenarioValidator([scenario])
+ sv.check_for_thens()
diff --git a/yarnlib/shell_libraries.py b/yarnlib/shell_libraries.py
new file mode 100644
index 0000000..3607adc
--- /dev/null
+++ b/yarnlib/shell_libraries.py
@@ -0,0 +1,45 @@
+# Copyright 2014 Lars Wirzenius and Codethink Limited
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+# =*= License: GPL-3+ =*=
+
+
+def load_shell_libraries(paths, pre_read_cb=lambda filename: None, open=open):
+ '''Helper for loading shell libraries from files.
+
+ `paths`: Iterable of file paths to shell libraries.
+ `pre_read_cb`: Optional callback for providing progress reporting
+ and logging.
+ It is given only one parameter, of the path to the
+ shell library that is about to be loaded.
+ `open`: Optional function for opening file paths. This is useful to
+ override how files are opened for unit tests or virtual
+ file systems.
+
+ This function is provided so that when shell libraries are loaded
+ by applications that use yarnlib, they can all load them with the
+ same format, including the comments about where the snippet came from,
+ and any future additions.
+
+ '''
+
+ libs = []
+ for filename in paths:
+ pre_read_cb(filename)
+ with open(filename) as f:
+ text = f.read()
+ libs.append('# Loaded from %s\n\n%s\n\n' % (filename, text))
+
+ return ''.join(libs)
diff --git a/yarnlib/shell_libraries_tests.py b/yarnlib/shell_libraries_tests.py
new file mode 100644
index 0000000..69e015a
--- /dev/null
+++ b/yarnlib/shell_libraries_tests.py
@@ -0,0 +1,59 @@
+# Copyright 2014 Codethink Limited
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+# =*= License: GPL-3+ =*=
+
+
+import contextlib
+import StringIO
+import unittest
+
+import yarnlib
+
+
+class ShellLibrariesTests(unittest.TestCase):
+
+ libfiles = {
+ 'die.shlib': '''
+ die() {
+ echo "$@" >&2
+ return 1
+ }
+ ''',
+ 'chdir.shlib':'''
+ chdir() {
+ (
+ cd "$1"
+ shift
+ exec "$@"
+ )
+ }
+ ''',
+ }
+
+ def fakeopen(self, path, *args, **kwargs):
+ return contextlib.closing(StringIO.StringIO(self.libfiles[path]))
+
+ def test_file_names_listed(self):
+ prelude = yarnlib.load_shell_libraries(self.libfiles.keys(),
+ open=self.fakeopen)
+ for filename in self.libfiles.iterkeys():
+ self.assertIn(filename, prelude)
+
+ def test_all_contents_included(self):
+ prelude = yarnlib.load_shell_libraries(self.libfiles.keys(),
+ open=self.fakeopen)
+ for contents in self.libfiles.itervalues():
+ self.assertIn(contents, prelude)