Skip to content

Commit ee2a41b

Browse files
Add Drush Test Traits (#4003)
1 parent 4108b5d commit ee2a41b

22 files changed

+465
-179
lines changed

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ Resources
1414
* [API Documentation](https://www.drush.org/api/master/)
1515
* [Drush packages available via Composer](https://packagist.org/?query=drush)
1616
* [A list of modules that include Drush integration](https://www.drupal.org/project/project_module?f[2]=im_vid_3%3A4654&solrsort=ds_project_latest_release+desc)
17-
* Drush comes with a [full test suite](https://github.com/drush-ops/drush/blob/master/tests/README.md) powered by [PHPUnit](https://github.com/sebastianbergmann/phpunit). Each commit gets tested by our CI bots.
17+
* [Testing Drush](https://github.com/drush-ops/drush/blob/master/tests/README.md) or your own Drush extensions with [PHPUnit](https://github.com/sebastianbergmann/phpunit).
1818

1919
Support
2020
-----------

examples/Commands/ArtCommands.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
* - http://cgit.drupalcode.org/devel/tree/devel_generate/src/Commands
2121
* - http://cgit.drupalcode.org/devel/tree/devel_generate/drush.services.yml
2222
*
23+
* For an example of a Drush extension with tests for Drush 9 and Drush 8:
24+
* - https://github.com/drush-ops/example-drush-extension
25+
*
2326
* This file is a good example of the first of those bullets (a commandfile) but
2427
* since it isn't part of a module, it does not implement drush.services.yml.
2528
*/

examples/Commands/PolicyCommands.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77

88
/**
99
* Load this commandfile using the --include option - e.g. `drush --include=/path/to/drush/examples`
10+
*
11+
* For an example of a Drush extension with tests for Drush 9 and Drush 8, see:
12+
* - https://github.com/drush-ops/example-drush-extension
1013
*/
1114

1215
class PolicyCommands extends DrushCommands

examples/Commands/XkcdCommands.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55

66
/**
77
* Run these commands using the --include option - e.g. `drush --include=/path/to/drush/examples xkcd`
8+
*
9+
* For an example of a Drush extension with tests for Drush 9 and Drush 8:
10+
* - https://github.com/drush-ops/example-drush-extension
811
*/
912

1013
class XkcdCommands extends DrushCommands

src/TestTraits/CliTestTrait.php

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
<?php
2+
namespace Drush\TestTraits;
3+
4+
use Symfony\Component\Process\Process;
5+
use Symfony\Component\Process\Exception\ProcessTimedOutException;
6+
7+
/**
8+
* CliTestTrait provides an `execute()` method that is useful
9+
* for launching executable programs in functional tests.
10+
*/
11+
trait CliTestTrait
12+
{
13+
use OutputUtilsTrait;
14+
15+
/**
16+
* Default timeout for commands.
17+
*
18+
* @var int
19+
*/
20+
private $defaultTimeout = 60;
21+
22+
/**
23+
* Timeout for command. Set to zero for no timeouts.
24+
*
25+
* Reset to $defaultTimeout after executing a command.
26+
*
27+
* @var int
28+
*/
29+
protected $timeout = 60;
30+
31+
/**
32+
* Default idle timeout for commands.
33+
*
34+
* @var int
35+
*/
36+
private $defaultIdleTimeout = 15;
37+
38+
/**
39+
* Idle timeouts for commands.
40+
*
41+
* Reset to $defaultIdleTimeout after executing a command.
42+
*
43+
* @var int
44+
*/
45+
protected $idleTimeout = 15;
46+
47+
/**
48+
* @var Process
49+
*/
50+
protected $process = null;
51+
52+
/**
53+
* Accessor for the last output, non-trimmed.
54+
*
55+
* @return string
56+
* Raw output as text.
57+
*
58+
* @access public
59+
*/
60+
public function getOutputRaw()
61+
{
62+
return $this->process ? $this->process->getOutput() : '';
63+
}
64+
65+
/**
66+
* Accessor for the last stderr output, non-trimmed.
67+
*
68+
* @return string
69+
* Raw stderr as text.
70+
*
71+
* @access public
72+
*/
73+
public function getErrorOutputRaw()
74+
{
75+
return $this->process ? $this->process->getErrorOutput() : '';
76+
}
77+
78+
/**
79+
* Actually runs the command.
80+
*
81+
* @param string $command
82+
* The actual command line to run.
83+
* @param integer $expected_return
84+
* The return code to expect
85+
* @param sting cd
86+
* The directory to run the command in.
87+
* @param array $env
88+
* Extra environment variables.
89+
* @param string $input
90+
* A string representing the STDIN that is piped to the command.
91+
*/
92+
public function execute($command, $expected_return = 0, $cd = null, $env = null, $input = null)
93+
{
94+
try {
95+
// Process uses a default timeout of 60 seconds, set it to 0 (none).
96+
$this->process = new Process($command, $cd, $env, $input, 0);
97+
$this->process->inheritEnvironmentVariables(true);
98+
if ($this->timeout) {
99+
$this->process->setTimeout($this->timeout)
100+
->setIdleTimeout($this->idleTimeout);
101+
}
102+
$return = $this->process->run();
103+
if ($expected_return !== $return) {
104+
$message = 'Unexpected exit code ' . $return . ' (expected ' . $expected_return . ") for command:\n" . $command;
105+
throw new \Exception($message . $this->buildProcessMessage());
106+
}
107+
// Reset timeouts to default.
108+
$this->timeout = $this->defaultTimeout;
109+
$this->idleTimeout = $this->defaultIdleTimeout;
110+
} catch (ProcessTimedOutException $e) {
111+
if ($e->isGeneralTimeout()) {
112+
$message = 'Command runtime exceeded ' . $this->timeout . " seconds:\n" . $command;
113+
} else {
114+
$message = 'Command had no output for ' . $this->idleTimeout . " seconds:\n" . $command;
115+
}
116+
throw new \Exception($message . $this->buildProcessMessage());
117+
}
118+
}
119+
120+
public static function escapeshellarg($arg)
121+
{
122+
// Short-circuit escaping for simple params (keep stuff readable)
123+
if (preg_match('|^[a-zA-Z0-9.:/_-]*$|', $arg)) {
124+
return $arg;
125+
} elseif (self::isWindows()) {
126+
return self::_escapeshellargWindows($arg);
127+
} else {
128+
return escapeshellarg($arg);
129+
}
130+
}
131+
132+
public static function isWindows()
133+
{
134+
return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
135+
}
136+
137+
public static function _escapeshellargWindows($arg)
138+
{
139+
// Double up existing backslashes
140+
$arg = preg_replace('/\\\/', '\\\\\\\\', $arg);
141+
142+
// Double up double quotes
143+
$arg = preg_replace('/"/', '""', $arg);
144+
145+
// Double up percents.
146+
$arg = preg_replace('/%/', '%%', $arg);
147+
148+
// Add surrounding quotes.
149+
$arg = '"' . $arg . '"';
150+
151+
return $arg;
152+
}
153+
154+
/**
155+
* Borrowed from \Symfony\Component\Process\Exception\ProcessTimedOutException
156+
*
157+
* @return string
158+
*/
159+
public function buildProcessMessage()
160+
{
161+
$error = sprintf(
162+
"%s\n\nExit Code: %s(%s)\n\nWorking directory: %s",
163+
$this->process->getCommandLine(),
164+
$this->process->getExitCode(),
165+
$this->process->getExitCodeText(),
166+
$this->process->getWorkingDirectory()
167+
);
168+
169+
if (!$this->process->isOutputDisabled()) {
170+
$error .= sprintf(
171+
"\n\nOutput:\n================\n%s\n\nError Output:\n================\n%s",
172+
$this->process->getOutput(),
173+
$this->process->getErrorOutput()
174+
);
175+
}
176+
177+
return $error;
178+
}
179+
180+
/**
181+
* Checks that the output matches the expected output.
182+
*
183+
* This matches against a simplified version of the actual output that has
184+
* absolute paths and duplicate whitespace removed, to avoid false negatives
185+
* on minor differences.
186+
*
187+
* @param string $expected
188+
* The expected output.
189+
* @param string $filter
190+
* Optional regular expression that should be ignored in the error output.
191+
*/
192+
193+
protected function assertOutputEquals($expected, $filter = '')
194+
{
195+
$output = $this->getSimplifiedOutput();
196+
if (!empty($filter)) {
197+
$output = preg_replace($filter, '', $output);
198+
}
199+
$this->assertEquals($expected, $output);
200+
}
201+
202+
/**
203+
* Checks that the error output matches the expected output.
204+
*
205+
* This matches against a simplified version of the actual output that has
206+
* absolute paths and duplicate whitespace removed, to avoid false negatives
207+
* on minor differences.
208+
*
209+
* @param string $expected
210+
* The expected output.
211+
* @param string $filter
212+
* Optional regular expression that should be ignored in the error output.
213+
*/
214+
protected function assertErrorOutputEquals($expected, $filter = '')
215+
{
216+
$output = $this->getSimplifiedErrorOutput();
217+
if (!empty($filter)) {
218+
$output = preg_replace($filter, '', $output);
219+
}
220+
$this->assertEquals($expected, $output);
221+
}
222+
}

src/TestTraits/DrushTestTrait.php

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
namespace Drush\TestTraits;
3+
4+
/**
5+
* DrushTestTrait provides a `drush()` method that may be
6+
* used to write functional tests for Drush extensions.
7+
*
8+
* See the [example-drush-extension](https://github.com/drush-ops/example-drush-extension) for example usage.
9+
*/
10+
trait DrushTestTrait
11+
{
12+
use CliTestTrait;
13+
14+
/**
15+
* @return string
16+
*/
17+
public function getPathToDrush()
18+
{
19+
return dirname(dirname(__DIR__)) . '/drush';
20+
}
21+
22+
/**
23+
* Invoke drush in via execute().
24+
*
25+
* @param command
26+
* A defined drush command such as 'cron', 'status' or any of the available ones such as 'drush pm'.
27+
* @param args
28+
* Command arguments.
29+
* @param $options
30+
* An associative array containing options.
31+
* @param $site_specification
32+
* A site alias or site specification. Include the '@' at start of a site alias.
33+
* @param $cd
34+
* A directory to change into before executing.
35+
* @param $expected_return
36+
* The expected exit code, e.g. 0 or 1 or some other expected value.
37+
* @param $suffix
38+
* Any code to append to the command. For example, redirection like 2>&1.
39+
* @param array $env
40+
* Environment variables to pass along to the subprocess.
41+
*/
42+
public function drush($command, array $args = [], array $options = [], $site_specification = null, $cd = null, $expected_return = 0, $suffix = null, $env = [])
43+
{
44+
$global_option_list = ['simulate', 'root', 'uri', 'include', 'config', 'alias-path', 'ssh-options'];
45+
$cmd[] = self::getPathToDrush();
46+
47+
// Insert global options.
48+
foreach ($options as $key => $value) {
49+
if (in_array($key, $global_option_list)) {
50+
unset($options[$key]);
51+
$cmd[] = $this->convertKeyValueToFlag($key, $value);
52+
}
53+
}
54+
55+
$cmd[] = "--no-interaction";
56+
57+
// Insert site specification and drush command.
58+
if (!empty($site_specification)) {
59+
$cmd[] = self::escapeshellarg($site_specification);
60+
}
61+
$cmd[] = $command;
62+
63+
// Insert drush command arguments.
64+
foreach ($args as $arg) {
65+
$cmd[] = self::escapeshellarg($arg);
66+
}
67+
// insert drush command options
68+
foreach ($options as $key => $value) {
69+
$cmd[] = $this->convertKeyValueToFlag($key, $value);
70+
}
71+
72+
$cmd[] = $suffix;
73+
$exec = array_filter($cmd, 'strlen'); // Remove NULLs
74+
// Set sendmail_path to 'true' to disable any outgoing emails
75+
// that tests might cause Drupal to send.
76+
77+
$cmd = implode(' ', $exec);
78+
$this->execute($cmd, $expected_return, $cd, $env);
79+
}
80+
81+
/**
82+
* Given an option key / value pair, convert to a
83+
* "--key=value" string.
84+
*
85+
* @param string $key The option name
86+
* @param string $value The option value (or empty)
87+
* @return string
88+
*/
89+
protected function convertKeyValueToFlag($key, $value)
90+
{
91+
if (!isset($value)) {
92+
return "--$key";
93+
}
94+
return "--$key=" . self::escapeshellarg($value);
95+
}
96+
97+
/**
98+
* Return the major version of Drush
99+
*
100+
* @return string e.g. "8" or "9"
101+
*/
102+
public function drushMajorVersion()
103+
{
104+
static $major;
105+
106+
if (!isset($major)) {
107+
$this->drush('version', [], ['field' => 'drush-version']);
108+
$version = trim($this->getOutput());
109+
list($major) = explode('.', $version);
110+
}
111+
return (int)$major;
112+
}
113+
}

0 commit comments

Comments
 (0)