Completed
Push — add/dynamic-autoloader-tests ( abcf7a )
by
unknown
414:34 queued 404:58
created

Test_Plugin_Factory::remove_directory()   B

Complexity

Conditions 9
Paths 11

Size

Total Lines 35

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 9
nc 11
nop 1
dl 0
loc 35
rs 8.0555
c 0
b 0
f 0
1
<?php
2
/**
3
 * Class file for the factory that generates test plugin data.
4
 *
5
 * @package automattic/jetpack-autoloader
6
 */
7
8
// phpcs:disable WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
9
// phpcs:disable WordPress.WP.AlternativeFunctions.file_system_read_file_put_contents
10
// phpcs:disable WordPress.WP.AlternativeFunctions.json_encode_json_encode
11
// phpcs:disable WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec
12
13
/**
14
 * Class Test_Plugin_Factory
15
 */
16
class Test_Plugin_Factory {
17
18
	/**
19
	 * The root namespace all of the test class files will live in.
20
	 */
21
	const TESTING_NAMESPACE = 'Automattic\\Jetpack\\AutoloaderTesting\\';
22
23
	/**
24
	 * The string representation of the current autoloader.
25
	 */
26
	const CURRENT = 'current';
27
28
	/**
29
	 * A constant for the autoloader version of a current plugin.
30
	 */
31
	const VERSION_CURRENT = '1000.0.0.0';
32
33
	/**
34
	 * Indicates whether or not the plugin is an mu-plugin.
35
	 *
36
	 * @var bool
37
	 */
38
	private $is_mu_plugin;
39
40
	/**
41
	 * The slug of the plugin we're creating.
42
	 *
43
	 * @var string
44
	 */
45
	private $slug;
46
47
	/**
48
	 * The composer autoloads that we're going to write to the configuration.
49
	 *
50
	 * @var array
51
	 */
52
	private $autoloads;
53
54
	/**
55
	 * The files that will be created as part of the plugin.
56
	 *
57
	 * @var array
58
	 */
59
	private $files;
60
61
	/**
62
	 * The version of the autoloader we want to utilize.
63
	 *
64
	 * @var string
65
	 */
66
	private $autoloader_version;
67
68
	/**
69
	 * The custom options we would like to pass to composer.
70
	 *
71
	 * @var string[]
72
	 */
73
	private $composer_options;
74
75
	/**
76
	 * Constructor.
77
	 *
78
	 * @param bool     $is_mu_plugin Indicates whether or not the plugin is an mu-plugin.
79
	 * @param string   $slug         The slug of the plugin.
80
	 * @param string[] $autoloads    The composer autoloads for the plugin.
81
	 */
82
	private function __construct( $is_mu_plugin, $slug, $autoloads ) {
83
		$this->is_mu_plugin = $is_mu_plugin;
84
		$this->slug         = $slug;
85
		$this->autoloads    = $autoloads;
86
	}
87
88
	/**
89
	 * Creates a new factory for the plugin and returns it.
90
	 *
91
	 * @param bool     $is_mu_plugin Indicates whether or not the plugin is an mu-plugin.
92
	 * @param string   $slug         The slug of the plugin we're building.
93
	 * @param string[] $autoloads    The composer autoloads for the plugin we're building.
94
	 * @return Test_Plugin_Factory
95
	 * @throws \InvalidArgumentException When the slug is invalid.
96
	 */
97
	public static function create( $is_mu_plugin, $slug, $autoloads ) {
98
		if ( false !== strpos( $slug, ' ' ) ) {
99
			throw new \InvalidArgumentException( 'Plugin slugs may not have spaces.' );
100
		}
101
102
		$slug = strtolower( preg_replace( '/[^A-Za-z0-9\-_]/', '', $slug ) );
103
104
		return new Test_Plugin_Factory( $is_mu_plugin, $slug, $autoloads );
105
	}
106
107
	/**
108
	 * Creates a new factory configured for a generic test plugin and returns it.
109
	 *
110
	 * @param bool   $is_mu_plugin Indicates whether or not the plugin is an mu-plugin.
111
	 * @param string $version      The version of the autoloader we want the plugin to use.
112
	 * @return Test_Plugin_Factory
113
	 */
114
	public static function create_test_plugin( $is_mu_plugin, $version ) {
115
		// We will use a global to detect when a file has been loaded by the autoloader.
116
		global $jetpack_autoloader_testing_loaded_files;
117
		if ( ! isset( $jetpack_autoloader_testing_loaded_files ) ) {
118
			$jetpack_autoloader_testing_loaded_files = array();
119
		}
120
121
		$file_version = $version;
122
		if ( self::CURRENT === $version ) {
123
			$file_version      = self::VERSION_CURRENT;
124
			$namespace_version = 'Current';
125
		} else {
126
			$namespace_version = 'v' . str_replace( '.', '_', $version );
127
		}
128
129
		// Avoid namespace collisions between plugins & mu-plugins.
130
		if ( $is_mu_plugin ) {
131
			$namespace_version .= 'mu';
132
		}
133
134
		// We need to define all of the autoloads that the files contained within will utilize.
135
		$autoloads = array(
136
			'classmap' => array( 'includes' ),
137
			'psr-4'    => array(
138
				self::TESTING_NAMESPACE => 'src',
139
			),
140
			'files'    => array( 'functions.php' ),
141
		);
142
143
		return self::create( $is_mu_plugin, str_replace( '.', '_', $version ), $autoloads )
0 ignored issues
show
Documentation introduced by
$autoloads is of type array<string,array<integ...,{\"0\":\"string\"}>"}>, but the function expects a array<integer,string>.

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...
144
			->with_class( 'classmap', 'Classmap_Test_Class', "\tconst VERSION = '$file_version';" )
145
			->with_class( 'psr-4', self::TESTING_NAMESPACE . 'SharedTestClass', "\tconst VERSION = '$file_version';" )
146
			->with_class( 'psr-4', self::TESTING_NAMESPACE . "$namespace_version\\UniqueTestClass", '' )
147
			->with_file( 'functions.php', "<?php\n\nglobal \$jetpack_autoloader_testing_loaded_files;\n\$jetpack_autoloader_testing_loaded_files[] = '$file_version';" )
148
			->with_autoloader_version( $version )
149
			->with_composer_config( array( 'config' => array( 'autoloader-suffix' => $namespace_version ) ) );
0 ignored issues
show
Documentation introduced by
array('config' => array(...=> $namespace_version)) is of type array<string,array<strin...uffix\":\"string\"}>"}>, but the function expects a array<integer,string>.

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...
150
	}
151
152
	/**
153
	 * Adds a file to the plugin being built.
154
	 *
155
	 * @param string $path    The path for the file in the plugin directory.
156
	 * @param string $content The content for the file.
157
	 * @return $this
158
	 */
159
	public function with_file( $path, $content ) {
160
		$this->files[ $path ] = $content;
161
		return $this;
162
	}
163
164
	/**
165
	 * Adds a class file to the plugin being built.
166
	 *
167
	 * @param string $autoload_type The type of autoloading to name the fule for. 'classmap', 'psr-4', 'psr-0' are options.
168
	 * @param string $fqn           The fully qualified name for the class.
169
	 * @param string $content       The content of the class.
170
	 * @return $this
171
	 * @throws \InvalidArgumentException When the input arguments are invalid.
172
	 */
173
	public function with_class( $autoload_type, $fqn, $content ) {
174
		if ( ! isset( $this->autoloads[ $autoload_type ] ) ) {
175
			throw new \InvalidArgumentException( 'The autoload type for this class is not registered with the factory.' );
176
		}
177
178
		// The path to the file depends on the type of autoloading it utilizes.
179
		$fqn = ltrim( $fqn, '\\' );
180
		if ( false !== strpos( $fqn, '\\' ) ) {
181
			$class_name = substr( $fqn, strripos( $fqn, '\\' ) + 1 );
182
			$namespace  = substr( $fqn, 0, -strlen( $class_name ) - 1 );
183
		} else {
184
			$class_name = $fqn;
185
			$namespace  = null;
186
		}
187
188
		$path = null;
189
		switch ( $autoload_type ) {
190
			case 'classmap':
191
				$path = 'includes' . DIRECTORY_SEPARATOR . 'class-' . strtolower( str_replace( '_', '-', $class_name ) ) . '.php';
192
				break;
193
194
			case 'psr-0':
195
			case 'psr-4':
196
				// Find the associated autoload entry so we can create the correct path.
197
				$autoload_namespaces = $this->autoloads[ $autoload_type ];
198
				foreach ( $autoload_namespaces as $autoload_namespace => $dir ) {
199
					if ( is_array( $dir ) ) {
200
						throw new \InvalidArgumentException( 'The factory only supports single mapping for PSR-0/PSR-4 namespaces.' );
201
					}
202
203
					$check = substr( $namespace . '\\', 0, strlen( $autoload_namespace ) );
204
					if ( $autoload_namespace !== $check ) {
205
						continue;
206
					}
207
208
					// Build a path using the rest of the namespace.
209
					$path      = $dir . DIRECTORY_SEPARATOR;
210
					$structure = explode( '\\', substr( $namespace, strlen( $check ) ) );
211
					foreach ( $structure as $s ) {
212
						$path .= $s . DIRECTORY_SEPARATOR;
213
					}
214
					break;
215
				}
216
217
				if ( ! isset( $path ) ) {
218
					throw new \InvalidArgumentException( 'The namespace for this class is not in the factory\'s autoloads.' );
219
				}
220
221
				// PSR-0 treats underscores in the class name as directory separators.
222
				$path .= str_replace( '_', 'psr-0' === $autoload_type ? DIRECTORY_SEPARATOR : '', $class_name ) . '.php';
223
				break;
224
225
			default:
226
				throw new \InvalidArgumentException( 'The given autoload type is invalid.' );
227
		}
228
229
		$file_content = "<?php\n\n";
230
		if ( isset( $namespace ) ) {
231
			$file_content .= "namespace $namespace;\n\n";
232
		}
233
		$file_content .= "class $class_name {\n$content\n}";
234
235
		return $this->with_file( $path, $file_content );
236
	}
237
238
	/**
239
	 * Declares the version of the autoloader that the plugin should use. When "current" is passed the package
240
	 * will use a symlink to the local package instead of an external dependency.
241
	 *
242
	 * @param string $version The version of autoloader to use. Pass "current" to use the local package.
243
	 * @return $this
244
	 */
245
	public function with_autoloader_version( $version ) {
246
		$this->autoloader_version = $version;
247
		return $this;
248
	}
249
250
	/**
251
	 * Adds options that will be passed to the plugin's composer.json file.
252
	 *
253
	 * @param string[] $options The options that we want to set in the composer config.
254
	 * @return $this
255
	 */
256
	public function with_composer_config( $options ) {
257
		$this->composer_options = $options;
258
		return $this;
259
	}
260
261
	/**
262
	 * Brings the plugin to life and returns the absolute path to the plugin directory.
263
	 *
264
	 * @return string
265
	 * @throws \RuntimeException When the factory fails to initialize composer.
266
	 */
267
	public function make() {
268
		if ( $this->is_mu_plugin ) {
269
			$plugin_dir = WPMU_PLUGIN_DIR . DIRECTORY_SEPARATOR . $this->slug;
270
		} else {
271
			$plugin_dir = WP_PLUGIN_DIR . DIRECTORY_SEPARATOR . $this->slug;
272
		}
273
274
		$plugin_file     = "<?php\n/**\n * Plugin Name: {$this->slug}\n */\n";
275
		$composer_config = $this->build_composer_config();
276
277
		// Don't write the plugin if it hasn't changed.
278
		if ( ! $this->has_plugin_changed( $plugin_dir, $plugin_file, $composer_config ) ) {
279
			return $plugin_dir;
280
		}
281
282
		// We want a clean directory to ensure files get removed.
283
		$this->remove_directory( $plugin_dir );
284
285
		// Start by writing the main plugin file.
286
		mkdir( $plugin_dir, 0777, true );
287
		file_put_contents( $plugin_dir . DIRECTORY_SEPARATOR . $this->slug . '.php', $plugin_file );
288
289
		// Write all of the files into the plugin directory.
290
		foreach ( $this->files as $path => $content ) {
291
			$dir = dirname( $plugin_dir . DIRECTORY_SEPARATOR . $path );
292
			if ( ! is_dir( $dir ) ) {
293
				mkdir( $dir, 0777, true );
294
			}
295
296
			file_put_contents( $plugin_dir . DIRECTORY_SEPARATOR . $path, $content );
297
		}
298
299
		// We also need to write the composer configuration for the plugin.
300
		file_put_contents(
301
			$plugin_dir . DIRECTORY_SEPARATOR . 'composer.json',
302
			json_encode( $composer_config, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES )
303
		);
304
305
		// We're assuming composer is installed and hope failures are distinct!
306
		exec( 'composer install -d ' . escapeshellarg( $plugin_dir ) . ' 2>&1' );
307
		if ( ! is_file( $plugin_dir . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 'autoload.php' ) ) {
308
			throw new \RuntimeException( 'Unable to execute the `composer` command for tests.' );
309
		}
310
311
		// Local autoloaders require using the branch but we may not want to treat it as a developer build.
312
		if ( $this->is_using_local_package() ) {
313
			$manifest_dir = $plugin_dir . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR . 'composer' . DIRECTORY_SEPARATOR;
314
			$manifests    = array( 'jetpack_autoload_classmap.php', 'jetpack_autoload_psr4.php', 'jetpack_autoload_psr0.php', 'jetpack_autoload_filemap.php' );
315
			foreach ( $manifests as $manifest ) {
316
				$manifest = $manifest_dir . $manifest;
317
				if ( ! is_file( $manifest ) ) {
318
					continue;
319
				}
320
321
				$content = file_get_contents( $manifest );
322
				// Use a sufficiently large version so that the local package will always be the latest autoloader.
323
				$content = str_replace( 'dev-master', self::VERSION_CURRENT, $content );
324
				file_put_contents( $manifest, $content );
325
			}
326
		}
327
328
		return $plugin_dir;
329
	}
330
331
	/**
332
	 * Indicates whether or not we are linking to the local package.
333
	 *
334
	 * @return bool
335
	 */
336
	private function is_using_local_package() {
337
		return self::CURRENT === $this->autoloader_version;
338
	}
339
340
	/**
341
	 * Creates and returns the configuration for the composer.json file.
342
	 *
343
	 * @return array
344
	 */
345
	private function build_composer_config() {
346
		$composer_config = array(
347
			'name'     => 'testing/' . $this->slug,
348
			'autoload' => $this->autoloads,
349
		);
350
		if ( $this->is_using_local_package() ) {
351
			$composer_config['require']      = array( 'automattic/jetpack-autoloader' => 'dev-master' );
352
			$composer_config['repositories'] = array(
353
				array(
354
					'type'    => 'path',
355
					'url'     => TEST_PACKAGE_DIR,
356
					'options' => array(
357
						'symlink' => true,
358
					),
359
				),
360
			);
361
		} elseif ( isset( $this->autoloader_version ) ) {
362
			$composer_config['require'] = array( 'automattic/jetpack-autoloader' => $this->autoloader_version );
363
		}
364
365
		if ( isset( $this->composer_options ) ) {
366
			$composer_config = array_merge( $composer_config, $this->composer_options );
367
		}
368
369
		return $composer_config;
370
	}
371
372
	/**
373
	 * Recursively removes a directory and all of its files.
374
	 *
375
	 * @param string $dir The directory to remove.
376
	 */
377
	private function remove_directory( $dir ) {
378
		if ( ! is_dir( $dir ) ) {
379
			return;
380
		}
381
382
		$empty_directories    = array();
383
		$directories_to_empty = array( $dir );
384
		// phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.FoundInWhileCondition
385
		while ( null !== ( $dir = array_shift( $directories_to_empty ) ) ) {
386
			$paths = scandir( $dir );
387
			foreach ( $paths as $path ) {
388
				if ( '.' === $path || '..' === $path ) {
389
					continue;
390
				}
391
				// Keep the path absolute.
392
				$path = $dir . DIRECTORY_SEPARATOR . $path;
393
394
				// Subdirectories need to be emptied before they can be deleted.
395
				// Take care not to follow symlinks as it will destroy everything.
396
				if ( is_dir( $path ) && ! is_link( $path ) ) {
397
					$directories_to_empty[] = $path;
398
					continue;
399
				}
400
401
				unlink( $path );
402
			}
403
404
			// Add to the front so that we delete children before parents.
405
			array_unshift( $empty_directories, $dir );
406
		}
407
408
		foreach ( $empty_directories as $dir ) {
409
			rmdir( $dir );
410
		}
411
	}
412
413
	/**
414
	 * Checks whether or not the plugin should be written to disk.
415
	 *
416
	 * @param string $plugin_dir      The directory we want to write the plugin to.
417
	 * @param string $plugin_file     The content for the plugin file.
418
	 * @param array  $composer_config The content for the composer.json file.
419
	 * @return bool
420
	 */
421
	private function has_plugin_changed( $plugin_dir, $plugin_file, &$composer_config ) {
422
		// Always write clean plugins.
423
		if ( ! is_file( $plugin_dir . DIRECTORY_SEPARATOR . 'composer.json' ) ) {
424
			return true;
425
		}
426
427
		// Prepare a checksum object for comparison and store it in the composer config so we can retrieve it later.
428
		$factory_checksum = array(
429
			'plugin'   => hash( 'crc32', $plugin_file ),
430
			'composer' => hash( 'crc32', json_encode( $composer_config ) ),
431
			'files'    => array(),
432
		);
433
		foreach ( $this->files as $path => $content ) {
434
			$factory_checksum['files'][ $path ] = hash( 'crc32', $content );
435
		}
436
437
		// When we're using the local package it is important that we also include the autoloader files in the checksum
438
		// since they would indicate a change in the package that warrants rebuilding the autoloader as well.
439
		if ( $this->is_using_local_package() ) {
440
			$factory_checksum['autoloader-files'] = array();
441
442
			$src_dir          = TEST_PACKAGE_DIR . DIRECTORY_SEPARATOR . 'src';
443
			$autoloader_files = scandir( $src_dir );
444
			foreach ( $autoloader_files as $file ) {
445
				if ( '.' === $file || '..' === $file ) {
446
					continue;
447
				}
448
449
				$factory_checksum['autoloader-files'][ $file ] = hash_file( 'crc32', $src_dir . DIRECTORY_SEPARATOR . $file );
450
			}
451
		}
452
453
		$composer_config['extra']['test-plugin-checksum'] = $factory_checksum;
454
455
		// Retrieve the checksum from the existing plugin so that we can detect whether or not the plugin has changed.
456
		$config = json_decode( file_get_contents( $plugin_dir . DIRECTORY_SEPARATOR . 'composer.json' ), true );
457
		if ( false === $config || ! isset( $config['extra']['test-plugin-checksum'] ) ) {
458
			return true;
459
		}
460
461
		// Only write the plugin to disk if it has changed.
462
		return $config['extra']['test-plugin-checksum'] !== $factory_checksum;
463
	}
464
}
465