Completed
Push — add/changelog-tooling ( e205c1...20e03c )
by
unknown
10:18
created

UtilsTest   A

Complexity

Total Complexity 19

Size/Duplication

Total Lines 423
Duplicated Lines 1.42 %

Coupling/Cohesion

Components 0
Dependencies 3

Importance

Changes 0
Metric Value
dl 6
loc 423
rs 10
c 0
b 0
f 0
wmc 19
lcom 0
cbo 3

9 Methods

Rating   Name   Duplication   Size   Complexity  
A test_error_clear_last() 0 9 1
A testRunCommand() 0 18 2
B provideRunCommand() 0 62 1
A testRunCommand_timeout() 0 12 2
A testLoadChangeFile() 0 23 3
B provideLoadChangeFile() 0 119 1
A testLoadChangeFile_badFile() 0 33 5
A testGetTimestamp() 6 43 2
A testLoadAllChanges() 0 48 2

How to fix   Duplicated Code   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

1
<?php // phpcs:ignore WordPress.Files.FileName.InvalidClassFileName
2
/**
3
 * Tests for the changelogger utils.
4
 *
5
 * @package automattic/jetpack-changelogger
6
 */
7
8
// phpcs:disable WordPress.NamingConventions.ValidVariableName, WordPress.WP.AlternativeFunctions
9
10
namespace Automattic\Jetpack\Changelogger\Tests;
11
12
use Automattic\Jetpack\Changelog\ChangeEntry;
13
use Automattic\Jetpack\Changelogger\FormatterPlugin;
14
use Automattic\Jetpack\Changelogger\Utils;
15
use Symfony\Component\Console\Helper\DebugFormatterHelper;
16
use Symfony\Component\Console\Output\BufferedOutput;
17
use Symfony\Component\Console\Output\ConsoleOutput;
18
use Symfony\Component\Console\Output\NullOutput;
19
use Symfony\Component\Process\Exception\ProcessTimedOutException;
20
use Symfony\Component\Process\ExecutableFinder;
21
use Symfony\Component\Process\Process;
22
use function Wikimedia\quietCall;
23
24
/**
25
 * Tests for the changelogger utils.
26
 *
27
 * @covers \Automattic\Jetpack\Changelogger\Utils
28
 */
29
class UtilsTest extends TestCase {
30
	use \Yoast\PHPUnitPolyfills\Polyfills\AssertIsType;
31
	use \Yoast\PHPUnitPolyfills\Polyfills\AssertionRenames;
32
	use \Yoast\PHPUnitPolyfills\Polyfills\ExpectException;
33
34
	/**
35
	 * Test error_clear_last.
36
	 */
37
	public function test_error_clear_last() {
38
		quietCall( 'trigger_error', 'Test', E_USER_NOTICE );
39
		$err = error_get_last();
40
		$this->assertSame( 'Test', $err['message'] );
41
42
		Utils::error_clear_last();
43
		$err = error_get_last();
44
		$this->assertTrue( empty( $err['message'] ) );
45
	}
46
47
	/**
48
	 * Test runCommand.
49
	 *
50
	 * @dataProvider provideRunCommand
51
	 * @param string $cmd Bash command string.
52
	 * @param array  $options Options for `runCommand()`.
53
	 * @param int    $expectExitCode Expected exit code.
54
	 * @param string $expectStdout Expected output from the command.
55
	 * @param string $expectStderr Expected output from the command.
56
	 * @param string $expectOutput Expected output to the console.
57
	 * @param int    $verbosity Output buffer verbosity.
58
	 */
59
	public function testRunCommand( $cmd, $options, $expectExitCode, $expectStdout, $expectStderr, $expectOutput, $verbosity = BufferedOutput::VERBOSITY_DEBUG ) {
60
		$sh = ( new ExecutableFinder() )->find( 'sh' );
61
		if ( ! $sh ) {
62
			$this->markTestSkipped( 'This test requires a Posix shell' );
63
		}
64
65
		$expectOutput = strtr( $expectOutput, array( '{SHELL}' => $sh ) );
66
67
		$output = new BufferedOutput();
68
		$output->setVerbosity( $verbosity );
69
		$helper = new DebugFormatterHelper();
70
		$ret    = Utils::runCommand( array( $sh, '-c', $cmd ), $output, $helper, $options );
71
		$this->assertInstanceOf( Process::class, $ret );
72
		$this->assertSame( $expectExitCode, $ret->getExitCode() );
73
		$this->assertSame( $expectStdout, $ret->getOutput() );
74
		$this->assertSame( $expectStderr, $ret->getErrorOutput() );
75
		$this->assertSame( $expectOutput, $output->fetch() );
76
	}
77
78
	/**
79
	 * Data provider for testRunCommand.
80
	 */
81
	public function provideRunCommand() {
82
		$tmp = sys_get_temp_dir();
83
84
		return array(
85
			'true'                      => array(
86
				'true',
87
				array(),
88
				0,
89
				'',
90
				'',
91
				"  RUN  '{SHELL}' '-c' 'true'\n\n",
92
			),
93
			'false'                     => array(
94
				'false',
95
				array(),
96
				1,
97
				'',
98
				'',
99
				"  RUN  '{SHELL}' '-c' 'false'\n\n",
100
			),
101
			'true, non-debug verbosity' => array(
102
				'true',
103
				array(),
104
				0,
105
				'',
106
				'',
107
				'',
108
				BufferedOutput::VERBOSITY_VERY_VERBOSE,
109
			),
110
			'With cwd'                  => array(
111
				'pwd',
112
				array(
113
					'cwd' => $tmp,
114
				),
115
				0,
116
				"$tmp\n",
117
				'',
118
				"  RUN  '{SHELL}' '-c' 'pwd'\n\n  OUT  $tmp\n  OUT  \n",
119
			),
120
			'With env'                  => array(
121
				'echo "$FOO" >&2',
122
				array(
123
					'env' => array( 'FOO' => 'FOOBAR' ),
124
				),
125
				0,
126
				'',
127
				"FOOBAR\n",
128
				"  RUN  '{SHELL}' '-c' 'echo \"\$FOO\" >&2'\n\n  ERR  FOOBAR\n  ERR  \n",
129
			),
130
			'With input'                => array(
131
				'while IFS= read X; do echo "{{$X}}"; done',
132
				array(
133
					'input' => "A\nB\nC\n",
134
				),
135
				0,
136
				"{{A}}\n{{B}}\n{{C}}\n",
137
				'',
138
				'',
139
				BufferedOutput::VERBOSITY_NORMAL,
140
			),
141
		);
142
	}
143
144
	/**
145
	 * Test runCommand with a timeout.
146
	 */
147
	public function testRunCommand_timeout() {
148
		$sleep = ( new ExecutableFinder() )->find( 'sleep' );
149
		if ( ! $sleep ) {
150
			$this->markTestSkipped( 'This test requires a "sleep" command' );
151
		}
152
153
		$output = new BufferedOutput();
154
		$output->setVerbosity( BufferedOutput::VERBOSITY_DEBUG );
155
		$helper = new DebugFormatterHelper();
156
		$this->expectException( ProcessTimedOutException::class );
157
		Utils::runCommand( array( $sleep, '1' ), $output, $helper, array( 'timeout' => 0.1 ) );
158
	}
159
160
	/**
161
	 * Test loadChangeFile.
162
	 *
163
	 * @dataProvider provideLoadChangeFile
164
	 * @param string                  $contents File contents.
165
	 * @param array|\RuntimeException $expect Expected output.
166
	 * @param array                   $expectDiagnostics Expected diagnostics.
167
	 */
168
	public function testLoadChangeFile( $contents, $expect, $expectDiagnostics = array() ) {
169
		$temp = tempnam( sys_get_temp_dir(), 'phpunit-testLoadChangeFile-' );
170
		try {
171
			file_put_contents( $temp, $contents );
172
			if ( ! $expect instanceof \RuntimeException ) {
173
				$diagnostics = null; // Make phpcs happy.
174
				$this->assertSame( $expect, Utils::loadChangeFile( $temp, $diagnostics ) );
175
				$this->assertSame( $expectDiagnostics, $diagnostics );
176
			} else {
177
				try {
178
					Utils::loadChangeFile( $temp );
179
					$this->fail( 'Expcected exception not thrown' );
180
				} catch ( \RuntimeException $ex ) {
181
					$this->assertInstanceOf( get_class( $expect ), $ex );
182
					$this->assertMatchesRegularExpression( $expect->getMessage(), $ex->getMessage() );
183
					$this->assertObjectHasAttribute( 'fileLine', $ex );
184
					$this->assertSame( $expect->fileLine, $ex->fileLine );
0 ignored issues
show
Bug introduced by
The property fileLine does not seem to exist in RuntimeException.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
185
				}
186
			}
187
		} finally {
188
			unlink( $temp );
189
		}
190
	}
191
192
	/**
193
	 * Data provider for testLoadChangeFile.
194
	 */
195
	public function provideLoadChangeFile() {
196
		$ex = function ( $msg, $line ) {
197
			$ret           = new \RuntimeException( $msg );
198
			$ret->fileLine = $line;
0 ignored issues
show
Bug introduced by
The property fileLine does not seem to exist in RuntimeException.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
199
			return $ret;
200
		};
201
		return array(
202
			'Normal file'                 => array(
203
				"Foo: bar baz\nQuux: XXX\n\nEntry\n",
204
				array(
205
					'Foo'  => 'bar baz',
206
					'Quux' => 'XXX',
207
					''     => 'Entry',
208
				),
209
				array(
210
					'warnings' => array(),
211
					'lines'    => array(
212
						'Foo'  => 1,
213
						'Quux' => 2,
214
						''     => 4,
215
					),
216
				),
217
			),
218
			'File with no entry'          => array(
219
				"Foo: bar baz\nQuux: XXX\n\n\n\n",
220
				array(
221
					'Foo'  => 'bar baz',
222
					'Quux' => 'XXX',
223
					''     => '',
224
				),
225
				array(
226
					'warnings' => array(),
227
					'lines'    => array(
228
						'Foo'  => 1,
229
						'Quux' => 2,
230
						''     => 6,
231
					),
232
				),
233
			),
234
			'Trimmed file with no entry'  => array(
235
				"Foo: bar baz\nQuux: XXX",
236
				array(
237
					'Foo'  => 'bar baz',
238
					'Quux' => 'XXX',
239
					''     => '',
240
				),
241
				array(
242
					'warnings' => array(),
243
					'lines'    => array(
244
						'Foo'  => 1,
245
						'Quux' => 2,
246
						''     => 2,
247
					),
248
				),
249
			),
250
			'File with no headers'        => array(
251
				"\nEntry\n",
252
				array(
253
					'' => 'Entry',
254
				),
255
				array(
256
					'warnings' => array(),
257
					'lines'    => array(
258
						'' => 2,
259
					),
260
				),
261
			),
262
			'Empty file'                  => array(
263
				'',
264
				array(
265
					'' => '',
266
				),
267
				array(
268
					'warnings' => array(),
269
					'lines'    => array(
270
						'' => 1,
271
					),
272
				),
273
			),
274
			'File with wrapped header'    => array(
275
				"Foo: bar\n  baz\n  \n  ok?\n\nThis is a multiline\nentry.\n",
276
				array(
277
					'Foo' => 'bar baz ok?',
278
					''    => "This is a multiline\nentry.",
279
				),
280
				array(
281
					'warnings' => array(),
282
					'lines'    => array(
283
						'Foo' => 1,
284
						''    => 6,
285
					),
286
				),
287
			),
288
			'File with duplicate headers' => array(
289
				"Foo: A\nFoo: B\nBar:\nFoo: C\nBar: X\n\nEntry\n",
290
				array(
291
					'Foo' => 'A',
292
					'Bar' => '',
293
					''    => 'Entry',
294
				),
295
				array(
296
					'warnings' => array(
297
						array( 'Duplicate header "Foo", previously seen on line 1.', 2 ),
298
						array( 'Duplicate header "Foo", previously seen on line 1.', 4 ),
299
						array( 'Duplicate header "Bar", previously seen on line 3.', 5 ),
300
					),
301
					'lines'    => array(
302
						'Foo' => 1,
303
						'Bar' => 3,
304
						''    => 7,
305
					),
306
				),
307
			),
308
			'Invalid header'              => array(
309
				"Foo: bar\nWrapped: A\n B\n C\nEntry.\n",
310
				$ex( '/^Invalid header.$/', 5 ),
311
			),
312
		);
313
	}
314
315
	/**
316
	 * Test "bad filename" paths in loadChangeFile.
317
	 */
318
	public function testLoadChangeFile_badFile() {
319
		try {
320
			Utils::loadChangeFile( 'doesnotexist/reallydoesnotexist.txt' );
321
			$this->fail( 'Expected exception not thrown' );
322
		} catch ( \RuntimeException $ex ) {
323
			$this->assertSame( 'File does not exist.', $ex->getMessage() );
324
			$this->assertNull( $ex->fileLine );
0 ignored issues
show
Bug introduced by
The property fileLine does not seem to exist in RuntimeException.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
325
		}
326
		try {
327
			Utils::loadChangeFile( '.' );
328
			$this->fail( 'Expected exception not thrown' );
329
		} catch ( \RuntimeException $ex ) {
330
			$this->assertSame( 'Expected a file, got dir.', $ex->getMessage() );
331
			$this->assertNull( $ex->fileLine );
332
		}
333
334
		// Try to create an unreadable file. May fail if tests are running as root.
335
		$temp = tempnam( sys_get_temp_dir(), 'phpunit-testLoadChangeFile-' );
336
		try {
337
			chmod( $temp, 0000 );
338
			if ( ! is_readable( $temp ) ) {
339
				try {
340
					Utils::loadChangeFile( $temp );
341
					$this->fail( 'Expected exception not thrown' );
342
				} catch ( \RuntimeException $ex ) {
343
					$this->assertSame( 'File is not readable.', $ex->getMessage() );
344
					$this->assertNull( $ex->fileLine );
345
				}
346
			}
347
		} finally {
348
			unlink( $temp );
349
		}
350
	}
351
352
	/**
353
	 * Test getTimestamp.
354
	 */
355
	public function testGetTimestamp() {
356
		$this->useTempDir();
357
358 View Code Duplication
		if ( in_array( '--debug', $GLOBALS['argv'], true ) ) {
359
			$output = new ConsoleOutput();
360
			$output->setVerbosity( ConsoleOutput::VERBOSITY_DEBUG );
361
		} else {
362
			$output = new NullOutput();
363
		}
364
		$helper = new DebugFormatterHelper();
365
366
		// Create a non-git file in a non-git checkout.
367
		touch( 'not-in-git.txt', 1614124800 );
368
		$this->assertSame( '2021-02-24T00:00:00Z', Utils::getTimestamp( 'not-in-git.txt', $output, $helper ) );
369
370
		// Create a file in a git checkout.
371
		file_put_contents( 'in-git.txt', '' );
372
		$args = array(
373
			$output,
374
			$helper,
375
			array(
376
				'mustRun' => true,
377
				'env'     => array(
378
					'GIT_AUTHOR_NAME'     => 'Dummy',
379
					'GIT_AUTHOR_EMAIL'    => '[email protected]',
380
					'GIT_AUTHOR_DATE'     => '2021-01-01T11:11:11Z',
381
					'GIT_COMMITTER_NAME'  => 'Dummy',
382
					'GIT_COMMITTER_EMAIL' => '[email protected]',
383
					'GIT_COMMITTER_DATE'  => '2021-02-02T22:22:22Z',
384
				),
385
			),
386
		);
387
		Utils::runCommand( array( 'git', 'init', '.' ), ...$args );
0 ignored issues
show
Documentation introduced by
$args is of type array<integer,object<Sym...\\\"string\\\"}>\"}>"}>, but the function expects a object<Symfony\Component...Output\OutputInterface>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
Bug introduced by
The call to runCommand() misses a required argument $formatter.

This check looks for function calls that miss required arguments.

Loading history...
388
		Utils::runCommand( array( 'git', 'add', 'in-git.txt' ), ...$args );
0 ignored issues
show
Bug introduced by
The call to runCommand() misses a required argument $formatter.

This check looks for function calls that miss required arguments.

Loading history...
Documentation introduced by
$args is of type array<integer,object<Sym...\\\"string\\\"}>\"}>"}>, but the function expects a object<Symfony\Component...Output\OutputInterface>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
389
		Utils::runCommand( array( 'git', 'commit', '-m', 'Commit' ), ...$args );
0 ignored issues
show
Bug introduced by
The call to runCommand() misses a required argument $formatter.

This check looks for function calls that miss required arguments.

Loading history...
Documentation introduced by
$args is of type array<integer,object<Sym...\\\"string\\\"}>\"}>"}>, but the function expects a object<Symfony\Component...Output\OutputInterface>.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
390
		$this->assertSame( '2021-02-02T22:22:22+00:00', Utils::getTimestamp( 'in-git.txt', $output, $helper ) );
391
392
		// Test our non-git file again.
393
		$this->assertSame( '2021-02-24T00:00:00Z', Utils::getTimestamp( 'not-in-git.txt', $output, $helper ) );
394
395
		// Nonexistent file.
396
		$this->assertNull( Utils::getTimestamp( 'missing.txt', $output, $helper ) );
397
	}
398
399
	/**
400
	 * Test loadAllChanges.
401
	 */
402
	public function testLoadAllChanges() {
403
		$formatter = $this->getMockBuilder( FormatterPlugin::class )
404
			->setMethodsExcept( array() )
405
			->getMock();
406
		$formatter->expects( $this->never() )->method( $this->logicalNot( $this->matches( 'newChangeEntry' ) ) );
407
		$formatter->method( 'newChangeEntry' )->willReturnCallback(
408
			function ( $data ) {
409
				return new ChangeEntry( $data );
410
			}
411
		);
412
413
		$dir = $this->useTempDir() . '/changes';
414
		mkdir( $dir );
415
416
		file_put_contents( "$dir/a", "Date: 2021-02-22T00:00:00Z\nSignificance: minor\nType: added\n\nAAAAA\n" );
417
		file_put_contents( "$dir/b", "Significance: minor\nType: unknown\nType: unknown\n\nBBBBB\n" );
418
		touch( "$dir/b", 1614124800 );
419
		file_put_contents( "$dir/c", "Significance: minor\nType: added\nCCCCC\n" );
420
		mkdir( "$dir/d" );
421
		file_put_contents( "$dir/e", "Significance: bogus\nType: added\n\nEEEEE\n" );
422
423
		$out   = new BufferedOutput();
424
		$files = null; // Make phpcs happy.
425
		$ret   = Utils::loadAllChanges( $dir, array( 'added' => 'Added!' ), $formatter, $out, $files );
426
		$this->assertIsArray( $ret );
427
		foreach ( $ret as $e ) {
428
			$this->assertInstanceOf( ChangeEntry::class, $e );
429
		}
430
431
		$this->assertSame(
432
			'{"a":{"__class__":"Automattic\\\\Jetpack\\\\Changelog\\\\ChangeEntry","significance":"minor","timestamp":"2021-02-22T00:00:00+00:00","subheading":"Added!","author":"","content":"AAAAA"},"b":{"__class__":"Automattic\\\\Jetpack\\\\Changelog\\\\ChangeEntry","significance":"minor","timestamp":"2021-02-24T00:00:00+00:00","subheading":"Unknown","author":"","content":"BBBBB"}}',
433
			json_encode( $ret )
434
		);
435
		$this->assertSame(
436
			array(
437
				'a' => 0,
438
				'b' => 1,
439
				'c' => 2,
440
				'd' => 2,
441
				'e' => 2,
442
			),
443
			$files
444
		);
445
		$this->assertSame(
446
			"<warning>b:3: Duplicate header \"Type\", previously seen on line 2.\nc: Invalid header.\nd: Expected a file, got dir.\ne: Automattic\\Jetpack\\Changelog\\ChangeEntry::setSignificance: Significance must be 'patch', 'minor', or 'major' (or null)\n",
447
			$out->fetch()
448
		);
449
	}
450
451
}
452