1<?php 2 3namespace Drupal\Tests\Composer\Plugin\Scaffold; 4 5use Composer\Console\Application; 6use Composer\Factory; 7use Composer\IO\BufferIO; 8use Composer\Util\Filesystem; 9use Drupal\Composer\Plugin\Scaffold\Handler; 10use Drupal\Composer\Plugin\Scaffold\Interpolator; 11use Drupal\Composer\Plugin\Scaffold\Operations\AppendOp; 12use Drupal\Composer\Plugin\Scaffold\Operations\ReplaceOp; 13use Drupal\Composer\Plugin\Scaffold\ScaffoldFilePath; 14use Symfony\Component\Console\Input\StringInput; 15use Symfony\Component\Console\Output\BufferedOutput; 16 17/** 18 * Convenience class for creating fixtures. 19 */ 20class Fixtures { 21 22 /** 23 * Keep a persistent prefix to help group our tmp directories together. 24 * 25 * @var string 26 */ 27 protected static $randomPrefix = ''; 28 29 /** 30 * Directories to delete when we are done. 31 * 32 * @var string[] 33 */ 34 protected $tmpDirs = []; 35 36 /** 37 * A Composer IOInterface to write to. 38 * 39 * @var \Composer\IO\IOInterface 40 */ 41 protected $io; 42 43 /** 44 * The composer object. 45 * 46 * @var \Composer\Composer 47 */ 48 protected $composer; 49 50 /** 51 * Gets an IO fixture. 52 * 53 * @return \Composer\IO\IOInterface 54 * A Composer IOInterface to write to; output may be retrieved via 55 * Fixtures::getOutput(). 56 */ 57 public function io() { 58 if (!$this->io) { 59 $this->io = new BufferIO(); 60 } 61 return $this->io; 62 } 63 64 /** 65 * Gets the Composer object. 66 * 67 * @return \Composer\Composer 68 * The main Composer object, needed by the scaffold Handler, etc. 69 */ 70 public function getComposer() { 71 if (!$this->composer) { 72 $this->composer = Factory::create($this->io(), NULL, TRUE); 73 } 74 return $this->composer; 75 } 76 77 /** 78 * Gets the output from the io() fixture. 79 * 80 * @return string 81 * Output captured from tests that write to Fixtures::io(). 82 */ 83 public function getOutput() { 84 return $this->io()->getOutput(); 85 } 86 87 /** 88 * Gets the path to Scaffold component. 89 * 90 * Used to inject the component into composer.json files. 91 * 92 * @return string 93 * Path to the root of this project. 94 */ 95 public function projectRoot() { 96 return realpath(__DIR__) . '/../../../../../../../composer/Plugin/Scaffold'; 97 } 98 99 /** 100 * Gets the path to the project fixtures. 101 * 102 * @return string 103 * Path to project fixtures 104 */ 105 public function allFixturesDir() { 106 return realpath(__DIR__ . '/fixtures'); 107 } 108 109 /** 110 * Gets the path to one particular project fixture. 111 * 112 * @param string $project_name 113 * The project name to get the path for. 114 * 115 * @return string 116 * Path to project fixture. 117 */ 118 public function projectFixtureDir($project_name) { 119 $dir = $this->allFixturesDir() . '/' . $project_name; 120 if (!is_dir($dir)) { 121 throw new \RuntimeException("Requested fixture project {$project_name} that does not exist."); 122 } 123 return $dir; 124 } 125 126 /** 127 * Gets the path to one particular bin path. 128 * 129 * @param string $bin_name 130 * The bin name to get the path for. 131 * 132 * @return string 133 * Path to project fixture. 134 */ 135 public function binFixtureDir($bin_name) { 136 $dir = $this->allFixturesDir() . '/scripts/' . $bin_name; 137 if (!is_dir($dir)) { 138 throw new \RuntimeException("Requested fixture bin dir {$bin_name} that does not exist."); 139 } 140 return $dir; 141 } 142 143 /** 144 * Gets a path to a source scaffold fixture. 145 * 146 * Use in place of ScaffoldFilePath::sourcePath(). 147 * 148 * @param string $project_name 149 * The name of the project to fetch; $package_name is 150 * "fixtures/$project_name". 151 * @param string $source 152 * The name of the asset; path is "assets/$source". 153 * 154 * @return \Drupal\Composer\Plugin\Scaffold\ScaffoldFilePath 155 * The full and relative path to the desired asset 156 * 157 * @see \Drupal\Composer\Plugin\Scaffold\ScaffoldFilePath::sourcePath() 158 */ 159 public function sourcePath($project_name, $source) { 160 $package_name = "fixtures/{$project_name}"; 161 $source_rel_path = "assets/{$source}"; 162 $package_path = $this->projectFixtureDir($project_name); 163 return ScaffoldFilePath::sourcePath($package_name, $package_path, 'unknown', $source_rel_path); 164 } 165 166 /** 167 * Gets an Interpolator with 'web-root' and 'package-name' set. 168 * 169 * Use in place of ManageOptions::getLocationReplacements(). 170 * 171 * @return \Drupal\Composer\Plugin\Scaffold\Interpolator 172 * An interpolator with location replacements, including 'web-root'. 173 * 174 * @see \Drupal\Composer\Plugin\Scaffold\ManageOptions::getLocationReplacements() 175 */ 176 public function getLocationReplacements() { 177 $destinationTmpDir = $this->mkTmpDir('location-replacements'); 178 $interpolator = new Interpolator(); 179 $interpolator->setData(['web-root' => $destinationTmpDir, 'package-name' => 'fixtures/tmp-destination']); 180 return $interpolator; 181 } 182 183 /** 184 * Creates a ReplaceOp fixture. 185 * 186 * @param string $project_name 187 * The name of the project to fetch; $package_name is 188 * "fixtures/$project_name". 189 * @param string $source 190 * The name of the asset; path is "assets/$source". 191 * 192 * @return \Drupal\Composer\Plugin\Scaffold\Operations\ReplaceOp 193 * A replace operation object. 194 */ 195 public function replaceOp($project_name, $source) { 196 $source_path = $this->sourcePath($project_name, $source); 197 return new ReplaceOp($source_path, TRUE); 198 } 199 200 /** 201 * Creates an AppendOp fixture. 202 * 203 * @param string $project_name 204 * The name of the project to fetch; $package_name is 205 * "fixtures/$project_name". 206 * @param string $source 207 * The name of the asset; path is "assets/$source". 208 * 209 * @return \Drupal\Composer\Plugin\Scaffold\Operations\AppendOp 210 * An append operation object. 211 */ 212 public function appendOp($project_name, $source) { 213 $source_path = $this->sourcePath($project_name, $source); 214 return new AppendOp(NULL, $source_path); 215 } 216 217 /** 218 * Gets a destination path in a tmp dir. 219 * 220 * Use in place of ScaffoldFilePath::destinationPath(). 221 * 222 * @param string $destination 223 * Destination path; should be in the form '[web-root]/robots.txt', where 224 * '[web-root]' is always literally '[web-root]', with any arbitrarily 225 * desired filename following. 226 * @param \Drupal\Composer\Plugin\Scaffold\Interpolator $interpolator 227 * Location replacements. Obtain via Fixtures::getLocationReplacements() 228 * when creating multiple scaffold destinations. 229 * @param string $package_name 230 * (optional) The name of the fixture package that this path came from. 231 * Taken from interpolator if not provided. 232 * 233 * @return \Drupal\Composer\Plugin\Scaffold\ScaffoldFilePath 234 * A destination scaffold file backed by temporary storage. 235 * 236 * @see \Drupal\Composer\Plugin\Scaffold\ScaffoldFilePath::destinationPath() 237 */ 238 public function destinationPath($destination, Interpolator $interpolator = NULL, $package_name = NULL) { 239 $interpolator = $interpolator ?: $this->getLocationReplacements(); 240 $package_name = $package_name ?: $interpolator->interpolate('[package-name]'); 241 return ScaffoldFilePath::destinationPath($package_name, $destination, $interpolator); 242 } 243 244 /** 245 * Generates a path to a temporary location, but do not create the directory. 246 * 247 * @param string $prefix 248 * A prefix for the temporary directory name. 249 * 250 * @return string 251 * Path to temporary directory 252 */ 253 public function tmpDir($prefix) { 254 $prefix .= static::persistentPrefix(); 255 $tmpDir = sys_get_temp_dir() . '/scaffold-' . $prefix . uniqid(md5($prefix . microtime()), TRUE); 256 $this->tmpDirs[] = $tmpDir; 257 return $tmpDir; 258 } 259 260 /** 261 * Generates a persistent prefix to use with all of our temporary directories. 262 * 263 * The presumption is that this should reduce collisions in highly-parallel 264 * tests. We prepend the process id to play nicely with phpunit process 265 * isolation. 266 * 267 * @return string 268 * A random string that will remain the same for the entire process run. 269 */ 270 protected static function persistentPrefix() { 271 if (empty(static::$randomPrefix)) { 272 static::$randomPrefix = getmypid() . md5(microtime()); 273 } 274 return static::$randomPrefix; 275 } 276 277 /** 278 * Creates a temporary directory. 279 * 280 * @param string $prefix 281 * A prefix for the temporary directory name. 282 * 283 * @return string 284 * Path to temporary directory 285 */ 286 public function mkTmpDir($prefix) { 287 $tmpDir = $this->tmpDir($prefix); 288 $filesystem = new Filesystem(); 289 $filesystem->ensureDirectoryExists($tmpDir); 290 return $tmpDir; 291 } 292 293 /** 294 * Create an isolated cache directory for Composer. 295 */ 296 public function createIsolatedComposerCacheDir() { 297 $cacheDir = $this->mkTmpDir('composer-cache'); 298 putenv("COMPOSER_CACHE_DIR=$cacheDir"); 299 } 300 301 /** 302 * Calls 'tearDown' in any test that copies fixtures to transient locations. 303 */ 304 public function tearDown(): void { 305 // Remove any temporary directories that were created. 306 $filesystem = new Filesystem(); 307 foreach ($this->tmpDirs as $dir) { 308 $filesystem->remove($dir); 309 } 310 // Clear out variables from the previous pass. 311 $this->tmpDirs = []; 312 $this->io = NULL; 313 // Clear the composer cache dir, if it was set 314 putenv('COMPOSER_CACHE_DIR='); 315 } 316 317 /** 318 * Creates a temporary copy of all of the fixtures projects into a temp dir. 319 * 320 * The fixtures remain dirty if they already exist. Individual tests should 321 * first delete any fixture directory that needs to remain pristine. Since all 322 * temporary directories are removed in tearDown, this is only an issue when 323 * a) the FIXTURE_DIR environment variable has been set, or b) tests are 324 * calling cloneFixtureProjects more than once per test method. 325 * 326 * @param string $fixturesDir 327 * The directory to place fixtures in. 328 * @param array $replacements 329 * Key : value mappings for placeholders to replace in composer.json 330 * templates. 331 */ 332 public function cloneFixtureProjects($fixturesDir, array $replacements = []) { 333 $filesystem = new Filesystem(); 334 // We will replace 'SYMLINK' with the string 'true' in our composer.json 335 // fixture. 336 $replacements += ['SYMLINK' => 'true']; 337 $interpolator = new Interpolator('__', '__'); 338 $interpolator->setData($replacements); 339 $filesystem->copy($this->allFixturesDir(), $fixturesDir); 340 $composer_json_templates = glob($fixturesDir . "/*/composer.json.tmpl"); 341 foreach ($composer_json_templates as $composer_json_tmpl) { 342 // Inject replacements into composer.json. 343 if (file_exists($composer_json_tmpl)) { 344 $composer_json_contents = file_get_contents($composer_json_tmpl); 345 $composer_json_contents = $interpolator->interpolate($composer_json_contents, [], FALSE); 346 file_put_contents(dirname($composer_json_tmpl) . "/composer.json", $composer_json_contents); 347 @unlink($composer_json_tmpl); 348 } 349 } 350 } 351 352 /** 353 * Runs the scaffold operation. 354 * 355 * This is equivalent to running `composer composer-scaffold`, but we do the 356 * equivalent operation by instantiating a Handler object in order to continue 357 * running in the same process, so that coverage may be calculated for the 358 * code executed by these tests. 359 * 360 * @param string $cwd 361 * The working directory to run the scaffold command in. 362 * 363 * @return string 364 * Output captured from tests that write to Fixtures::io(). 365 */ 366 public function runScaffold($cwd) { 367 chdir($cwd); 368 $handler = new Handler($this->getComposer(), $this->io()); 369 $handler->scaffold(); 370 return $this->getOutput(); 371 } 372 373 /** 374 * Runs a `composer` command. 375 * 376 * @param string $cmd 377 * The Composer command to execute (escaped as required) 378 * @param string $cwd 379 * The current working directory to run the command from. 380 * 381 * @return string 382 * Standard output and standard error from the command. 383 */ 384 public function runComposer($cmd, $cwd) { 385 chdir($cwd); 386 $input = new StringInput($cmd); 387 $output = new BufferedOutput(); 388 $application = new Application(); 389 $application->setAutoExit(FALSE); 390 $exitCode = $application->run($input, $output); 391 $output = $output->fetch(); 392 if ($exitCode != 0) { 393 throw new \Exception("Fixtures::runComposer failed to set up fixtures.\n\nCommand: '{$cmd}'\nExit code: {$exitCode}\nOutput: \n\n$output"); 394 } 395 return $output; 396 } 397 398} 399