From 8df14e9e26ce4d1ae3a17cef402401027a1d9de0 Mon Sep 17 00:00:00 2001 From: codad5 Date: Mon, 12 Oct 2026 09:54:35 +0100 Subject: [PATCH] feat(cli): build to a directory and add a quiet mode toDirectory() runs the same steps and verification as zip() and writes a folder, for rsync or SVN deploys, test sites, or inspecting what ships. The target is replaced only if it is empty or an earlier build of the same project; the project itself or a folder above it is refused, with paths resolved and compared without case. quiet() prints only warnings. Packaging moves into ZipPackager and DirectoryPackager. Co-Authored-By: Claude Opus 5.5 --- docs/guides/getting-started.md | 18 ++ docs/phases/track-p-packager.md | 4 +- docs/reference/work-log.md | 3 + packages/build/bin/wptoolkit-build | 15 +- packages/build/src/Build.php | 136 ++++++----- packages/build/src/BuildContext.php | 15 +- packages/build/src/BuildReport.php | 19 +- packages/build/src/DirectoryPackager.php | 122 ++++++++++ packages/build/src/JsonConfig.php | 3 + packages/build/src/StagedFiles.php | 47 ++++ packages/build/src/ZipPackager.php | 63 +++++ tests/E2E/build-fixtures.php | 10 +- tests/Unit/Build/DirectoryAndQuietTest.php | 263 +++++++++++++++++++++ 13 files changed, 630 insertions(+), 88 deletions(-) create mode 100644 packages/build/src/DirectoryPackager.php create mode 100644 packages/build/src/StagedFiles.php create mode 100644 packages/build/src/ZipPackager.php create mode 100644 tests/Unit/Build/DirectoryAndQuietTest.php diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 4470dee..e421934 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -121,5 +121,23 @@ Commands the build runs (`npm run build`, Composer) are silent unless they fail. `--verbose` (`wptoolkit-build package --verbose`), set `"verbose": true` in `wptoolkit.json`, or in a `build.php` call `->verbose()` for every command or `->runVerbose('npm run build')` for one. +A `build.php` gives you the rest: + +```php +Build::plugin(__DIR__)->run('npm run build')->zip(); // dist/{slug}-{version}.zip + .sha256 +Build::plugin(__DIR__)->run('npm run build')->toDirectory('dist/{slug}'); // the same build, as a folder +$files = Build::plugin(__DIR__)->dryRun(); // what the exclude rules leave; runs nothing +``` + +- `zip()` and `toDirectory()` **print the summary themselves**; don't `echo $report->summary()` as well. + The returned report is for scripts that need the numbers (`$report->sha256`, `->bytes`, `->files`). +- `->quiet()` (`--quiet`, `"quiet": true`) prints only warnings; failures are still exceptions. +- `toDirectory()` (`--dir=PATH`) runs every step and the same verification as `zip()`. Use it to rsync or + commit to SVN, to mount the built plugin in a test site, or to inspect what ships. The folder is + **replaced**, so it must be for output only: the project itself, a folder above it, or an existing + folder that is neither empty nor an earlier build of this project is refused. +- `dryRun()` (`--dry-run`) runs no commands and no verification, so files that steps create (built + assets, `vendor/`) are not in its list. For "what exactly ships", use `toDirectory()`. + Next: [routes and security](routes-and-security.md), [data](data.md), [admin and front end](admin-and-front-end.md). diff --git a/docs/phases/track-p-packager.md b/docs/phases/track-p-packager.md index 577195a..6e43c8b 100644 --- a/docs/phases/track-p-packager.md +++ b/docs/phases/track-p-packager.md @@ -65,7 +65,9 @@ php wptoolkit/bin/wptoolkit package # standalone install (no Composer) Flags override config: `--out`, `--zip-name`, `--no-build`, `--no-composer`, `--no-scope`, `--pot`, `--json` (machine-readable report for CI). `--verbose` (added 2026-10-12) prints each command's output as it runs; `"verbose": true` and `Build::verbose()` / `runVerbose()` do the same from config -and from a build script. +and from a build script. `--quiet` / `Build::quiet()` print only warnings. `--dir=PATH` / +`Build::toDirectory()` (added 2026-10-12) write a folder instead of a zip, with the same steps and +verification; the target is replaced only if it is empty or an earlier build of the same project. **Configuration** is one optional `wptoolkit.json` at the project root. It's JSON so PHP reads it without dependencies, and it has a JSON Schema so editors autocomplete it: diff --git a/docs/reference/work-log.md b/docs/reference/work-log.md index 53493bd..8cf05b7 100644 --- a/docs/reference/work-log.md +++ b/docs/reference/work-log.md @@ -58,3 +58,6 @@ One dated entry per working session: what landed, what was learned, what's next. which ADR-0014 promised and 0.x's `Autoloader::init()` used to do. The E2E fixtures now rely on it. - **Landed:** `Build::verbose()`, `runVerbose()`, `--verbose` and `"verbose": true`: command output streamed as it runs (`StreamingCommandRunner`); a failure after streaming repeats only the last 20 lines. +- **Landed:** `Build::toDirectory()` (`--dir`) and `Build::quiet()` (`--quiet`); packaging moved out of `Build` into + `ZipPackager` / `DirectoryPackager`. **Learned:** a folder target compared as a string let `.` and a + different letter case through as "not the project" — paths are now resolved and compared without case. diff --git a/packages/build/bin/wptoolkit-build b/packages/build/bin/wptoolkit-build index 90349ba..a2b2b6b 100644 --- a/packages/build/bin/wptoolkit-build +++ b/packages/build/bin/wptoolkit-build @@ -4,7 +4,7 @@ /** * wptoolkit-build — package a WordPress plugin or theme from wptoolkit.json (or defaults). * - * vendor/bin/wptoolkit-build package [--project=DIR] [--zip=PATH] [--dry-run] [--verbose] + * vendor/bin/wptoolkit-build package [--project=DIR] [--zip=PATH | --dir=PATH] [--dry-run] [--verbose] [--quiet] * vendor/bin/wptoolkit-build verify FILE.zip * vendor/bin/wptoolkit-build doctor [--project=DIR] * vendor/bin/wptoolkit-build init [--project=DIR] @@ -60,6 +60,13 @@ try { if (in_array('--verbose', $argv, true)) { $build->verbose(); } + if (in_array('--quiet', $argv, true)) { + $build->quiet(); + } + if (isset($options['dir'])) { + $build->toDirectory($options['dir']); + exit(0); + } $build->zip($options['zip'] ?? $zip); exit(0); @@ -101,8 +108,10 @@ try { default: fwrite(STDOUT, "Usage:\n" - . " wptoolkit-build package [--project=DIR] [--zip=PATH] [--dry-run] [--verbose]\n" - . " --verbose print the output of every command as it runs\n" + . " wptoolkit-build package [--project=DIR] [--zip=PATH | --dir=PATH] [--dry-run] [--verbose] [--quiet]\n" + . " --dir=PATH write a folder instead of a zip (replaced if it is an earlier build)\n" + . " --verbose print the output of every command as it runs\n" + . " --quiet print only warnings and errors\n" . " wptoolkit-build verify FILE.zip\n" . " wptoolkit-build doctor [--project=DIR]\n" . " wptoolkit-build init [--project=DIR]\n"); diff --git a/packages/build/src/Build.php b/packages/build/src/Build.php index 04dd8b8..e53f5cb 100644 --- a/packages/build/src/Build.php +++ b/packages/build/src/Build.php @@ -41,9 +41,6 @@ */ final class Build { - /** 1980-01-01, the earliest time a zip entry can hold. */ - private const FIXED_MTIME = 315532800; - /** Never copied into staging. */ private const NEVER_STAGED = ['.git', 'node_modules', '.svn']; @@ -67,6 +64,8 @@ final class Build private bool $verbose = false; + private bool $quiet = false; + /** @var list */ private array $excludes = []; @@ -151,6 +150,17 @@ public function verbose(bool $verbose = true): self return $this; } + /** + * Print nothing but warnings: no progress lines and no summary. The report is still returned, + * and a failure is still an exception. Wins over verbose(). + */ + public function quiet(bool $quiet = true): self + { + $this->quiet = $quiet; + + return $this; + } + /** * Reinstall Composer dependencies in staging; `noDev` drops dev packages. */ @@ -240,7 +250,8 @@ public function withOutput(Closure $output): self /** * Run the build and write the zip. `{slug}` and `{version}` are replaced; a relative path is - * relative to the project. A `.sha256` file is written next to it. + * relative to the project. A `.sha256` file is written next to it. The summary is printed for + * you (see quiet()); the returned report is for scripts that need the numbers. */ public function zip(string $target = 'dist/{slug}-{version}.zip'): BuildReport { @@ -248,21 +259,60 @@ public function zip(string $target = 'dist/{slug}-{version}.zip'): BuildReport throw new BuildException('The PHP zip extension is required to build packages.'); } - $version = ($this->version ?? Version::fromHeader())->resolve($this->project, $this->runner); + $version = $this->resolveVersion(); $zip = $this->absolute(strtr($target, ['{slug}' => $this->project->slug, '{version}' => $version])); - $staging = sys_get_temp_dir() . '/wptoolkit-build-' . bin2hex(random_bytes(6)) . '/' . $this->project->slug; - $context = new BuildContext($this->project, $staging, $version, $this->runner, $this->output, $this->verbose); + return $this->produce($version, [dirname($zip)], static fn (BuildContext $context): BuildReport => (new ZipPackager())->write($context, $zip)); + } + + /** + * Run the same build and verification, and write the result as a folder instead of a zip — to + * rsync or commit to SVN, to mount in a test site, or to look at exactly what ships. + * + * The folder is replaced, so it must be for output only: a target that is the project or a + * folder above it is refused, and so is an existing folder that is neither empty nor an + * earlier build of this project. Nothing is written if a step or verification fails. + */ + public function toDirectory(string $target = 'dist/{slug}'): BuildReport + { + $version = $this->resolveVersion(); + $directory = DirectoryPackager::normalize($this->absolute(strtr($target, ['{slug}' => $this->project->slug, '{version}' => $version]))); + $packager = new DirectoryPackager(); + $packager->assertSafe($this->project, $directory); // before any command runs + + $skip = [$directory]; + if (dirname($directory) !== $this->project->root) { + $skip[] = dirname($directory); // e.g. dist/, which may hold earlier zips and builds + } + + return $this->produce($version, $skip, static fn (BuildContext $context): BuildReport => $packager->write($context, $directory)); + } + + private function resolveVersion(): string + { + return ($this->version ?? Version::fromHeader())->resolve($this->project, $this->runner); + } + + /** + * The pipeline both outputs share: Prepare → stage → Transform → prune → Verify → write. + * + * @param list $notStaged Absolute paths left out of the staging copy (the output's own location). + * @param Closure(BuildContext): BuildReport $write + */ + private function produce(string $version, array $notStaged, Closure $write): BuildReport + { + $staging = sys_get_temp_dir() . '/wptoolkit-build-' . bin2hex(random_bytes(6)) . '/' . $this->project->slug; + $context = new BuildContext($this->project, $staging, $version, $this->runner, $this->output, $this->verbose, $this->quiet); $context->say(sprintf('Building %s %s (%s)', $this->project->slug, $version, $this->project->type)); try { $this->runPhase(Phase::Prepare, $context); - $this->stage($staging, $zip); + $this->stage($staging, $notStaged); $this->runPhase(Phase::Transform, $context); $this->prune($staging); $this->runPhase(Phase::Verify, $context, [new Verify($this->forbid, $this->maxSizeMb)]); - $report = $this->package($context, $zip); + $report = $write($context); $context->say($report->summary()); return $report; @@ -282,7 +332,7 @@ public function dryRun(): array $staging = sys_get_temp_dir() . '/wptoolkit-dryrun-' . bin2hex(random_bytes(6)) . '/' . $this->project->slug; try { - $this->stage($staging, $this->project->root . '/dist/.dry-run.zip'); + $this->stage($staging, [$this->project->root . '/dist']); $this->prune($staging); $files = []; @@ -324,18 +374,21 @@ private function runPhase(Phase $phase, BuildContext $context, array $extra = [] } } - private function stage(string $staging, string $zip): void + /** + * @param list $notStaged Absolute paths to leave out. + */ + private function stage(string $staging, array $notStaged): void { $root = $this->project->root; - $outputDirectory = str_replace('\\', '/', dirname($zip)); + $skipped = array_map(static fn (string $path): string => rtrim(str_replace('\\', '/', $path), '/'), $notStaged); mkdir($staging, 0777, true); $items = new RecursiveIteratorIterator( new \RecursiveCallbackFilterIterator( new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), - static function (\SplFileInfo $item) use ($outputDirectory): bool { + static function (\SplFileInfo $item) use ($skipped): bool { $path = str_replace('\\', '/', $item->getPathname()); - return !in_array($item->getFilename(), self::NEVER_STAGED, true) && $path !== $outputDirectory; + return !in_array($item->getFilename(), self::NEVER_STAGED, true) && !in_array($path, $skipped, true); } ), RecursiveIteratorIterator::SELF_FIRST @@ -387,61 +440,6 @@ private function expand(array $patterns): array return $expanded; } - private function package(BuildContext $context, string $zipPath): BuildReport - { - if (!is_dir(dirname($zipPath))) { - mkdir(dirname($zipPath), 0777, true); - } - if (is_file($zipPath)) { - unlink($zipPath); - } - - $files = []; - $sizes = []; - $total = 0; - $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($context->staging, FilesystemIterator::SKIP_DOTS)); - foreach ($iterator as $file) { - $relative = substr(str_replace('\\', '/', $file->getPathname()), strlen($context->staging) + 1); - $files[$relative] = $file->getPathname(); - $top = explode('/', $relative)[0] . (str_contains($relative, '/') ? '/' : ''); - $sizes[$top] = ($sizes[$top] ?? 0) + $file->getSize(); - $total += $file->getSize(); - } - ksort($files, SORT_STRING); // deterministic order - - $archive = new ZipArchive(); - if ($archive->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { - throw new BuildException(sprintf('Cannot write %s.', $zipPath)); - } - - // A single folder named after the slug, the way WordPress expects plugin/theme zips. - // Fixed timestamps and permissions: the same sources produce the same bytes, so a checksum - // proves what was released. libzip also reads the file's own mtime, hence the touch(). - $prefix = $this->project->slug . '/'; - foreach ($files as $relative => $absolute) { - $name = $prefix . $relative; - touch($absolute, self::FIXED_MTIME); - $archive->addFile($absolute, $name); - $archive->setMtimeName($name, self::FIXED_MTIME); - $archive->setExternalAttributesName($name, ZipArchive::OPSYS_UNIX, 0100644 << 16); - } - $archive->close(); - - $sha256 = (string) hash_file('sha256', $zipPath); - file_put_contents($zipPath . '.sha256', $sha256 . ' ' . basename($zipPath) . PHP_EOL); - arsort($sizes); - - return new BuildReport( - $zipPath, - $sha256, - $context->version, - $total, - array_map(static fn (string $f): string => $prefix . $f, array_keys($files)), - $sizes, - $context->warnings - ); - } - private function absolute(string $path): string { $path = str_replace('\\', '/', $path); diff --git a/packages/build/src/BuildContext.php b/packages/build/src/BuildContext.php index c053917..663a50e 100644 --- a/packages/build/src/BuildContext.php +++ b/packages/build/src/BuildContext.php @@ -27,7 +27,8 @@ public function __construct( public readonly string $version, public readonly CommandRunner $runner, private readonly Closure $output, - public readonly bool $verbose = false + public readonly bool $verbose = false, + public readonly bool $quiet = false ) { } @@ -83,15 +84,23 @@ private static function lines(string $output): array return $output === '' ? [] : explode("\n", $output); } + /** + * Progress output. Nothing is printed for a quiet build. + */ public function say(string $line): void { - ($this->output)($line); + if (!$this->quiet) { + ($this->output)($line); + } } + /** + * Something a person should read. Recorded on the report and printed even for a quiet build. + */ public function warn(string $line): void { $this->warnings[] = $line; - $this->say(' ! ' . $line); + ($this->output)(' ! ' . $line); } /** diff --git a/packages/build/src/BuildReport.php b/packages/build/src/BuildReport.php index f3829de..cda8c9a 100644 --- a/packages/build/src/BuildReport.php +++ b/packages/build/src/BuildReport.php @@ -14,9 +14,13 @@ final class BuildReport { /** - * @param list $files Paths inside the zip (under the top-level folder), sorted. + * @param string $zip The zip written, or '' for a directory build. + * @param string $sha256 The zip's checksum, or '' for a directory build. + * @param list $files Paths inside the zip (under its top-level folder), or relative to the + * directory for a directory build; sorted. * @param array $largestDirectories Top-level directory => bytes, largest first. * @param list $warnings + * @param string $directory The folder written by `toDirectory()`, or '' for a zip build. */ public function __construct( public readonly string $zip, @@ -25,17 +29,24 @@ public function __construct( public readonly int $bytes, public readonly array $files, public readonly array $largestDirectories, - public readonly array $warnings + public readonly array $warnings, + public readonly string $directory = '' ) { } + /** + * What `zip()` and `toDirectory()` print when they finish; you don't need to echo it yourself. + */ public function summary(): string { - $lines = [sprintf('%s %s, %d files, %.1f KB', basename($this->zip), $this->version, count($this->files), $this->bytes / 1024)]; + $name = $this->directory !== '' ? basename($this->directory) . '/' : basename($this->zip); + $lines = [sprintf('%s %s, %d files, %.1f KB', $name, $this->version, count($this->files), $this->bytes / 1024)]; foreach (array_slice($this->largestDirectories, 0, 5, true) as $directory => $bytes) { $lines[] = sprintf(' %-30s %8.1f KB', $directory, $bytes / 1024); } - $lines[] = 'sha256 ' . $this->sha256; + if ($this->sha256 !== '') { + $lines[] = 'sha256 ' . $this->sha256; + } return implode(PHP_EOL, $lines); } diff --git a/packages/build/src/DirectoryPackager.php b/packages/build/src/DirectoryPackager.php new file mode 100644 index 0000000..0b75415 --- /dev/null +++ b/packages/build/src/DirectoryPackager.php @@ -0,0 +1,122 @@ +root); + + // Compared without case, and through realpath() when the folder exists: on Windows and macOS + // `C:/Sites/Plugin` and `c:/sites/plugin` are one folder, and so is a symlink to it. Treating + // two folders that differ only by case as the same one costs a refusal; the opposite mistake + // would empty the project. + $same = static fn (string $path): string => strtolower(self::normalize((string) (realpath($path) ?: $path))); + if ($same($target) === $same($root) || str_starts_with($same($root) . '/', $same($target) . '/')) { + throw new BuildException(sprintf('toDirectory("%s") is the project itself or a folder above it; choose a folder for the output only.', $target)); + } + if (!file_exists($target)) { + return; + } + if (!is_dir($target)) { + throw new BuildException(sprintf('toDirectory("%s") is a file, not a directory.', $target)); + } + + // An earlier build has this project's own main file (plugin file, style.css) at its top. + $main = $target . '/' . $project->relativeHeaderFile(); + $isEarlierBuild = match ($project->type) { + 'plugin' => Project::readHeader($main, 'Plugin Name') !== null, + 'theme' => Project::readHeader($main, 'Theme Name') !== null, + default => is_file($main), + }; + $isEmpty = !(new FilesystemIterator($target))->valid(); + if (!$isEmpty && !$isEarlierBuild) { + throw new BuildException(sprintf( + 'toDirectory("%s") is not empty and is not an earlier build of this project, so it was left alone. Empty it or choose another folder.', + $target + )); + } + } + + public function write(BuildContext $context, string $target): BuildReport + { + $target = self::normalize($target); + $this->assertSafe($context->project, $target); + + self::empty($target); + $staged = StagedFiles::of($context->staging); + foreach ($staged->files as $relative => $absolute) { + $destination = $target . '/' . $relative; + if (!is_dir(dirname($destination)) && !mkdir(dirname($destination), 0777, true) && !is_dir(dirname($destination))) { + throw new BuildException(sprintf('Cannot create %s.', dirname($destination))); + } + if (!copy($absolute, $destination)) { + throw new BuildException(sprintf('Cannot write %s.', $destination)); + } + } + + return new BuildReport('', '', $context->version, $staged->bytes, array_keys($staged->files), $staged->sizes, $context->warnings, $target); + } + + /** + * Forward slashes, no trailing slash, and `.` / `..` resolved — so `out/..` or `./` can't pass + * for a different folder than the one they point at. The path need not exist. + */ + public static function normalize(string $path): string + { + $path = str_replace('\\', '/', $path); + $prefix = preg_match('#^([a-z]:)?/#i', $path, $m) === 1 ? $m[0] : ''; + $segments = []; + foreach (explode('/', substr($path, strlen($prefix))) as $segment) { + if ($segment === '' || $segment === '.') { + continue; + } + if ($segment === '..') { + array_pop($segments); + continue; + } + $segments[] = $segment; + } + + return rtrim($prefix . implode('/', $segments), '/'); + } + + private static function empty(string $directory): void + { + if (!is_dir($directory)) { + mkdir($directory, 0777, true); + return; + } + + $items = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($items as $item) { + $item->isDir() && !$item->isLink() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + } +} diff --git a/packages/build/src/JsonConfig.php b/packages/build/src/JsonConfig.php index 944f274..f79dc7d 100644 --- a/packages/build/src/JsonConfig.php +++ b/packages/build/src/JsonConfig.php @@ -50,6 +50,9 @@ public static function load(string $projectDirectory, string $file = 'wptoolkit. if (!empty($config['verbose'])) { $build->verbose(); } + if (!empty($config['quiet'])) { + $build->quiet(); + } if (isset($config['version'])) { $build->version(self::versionSource((string) $config['version'])); } diff --git a/packages/build/src/StagedFiles.php b/packages/build/src/StagedFiles.php new file mode 100644 index 0000000..0566500 --- /dev/null +++ b/packages/build/src/StagedFiles.php @@ -0,0 +1,47 @@ + $files Relative path => absolute path, sorted (a deterministic order). + * @param array $sizes Top-level entry => bytes, largest first. + */ + private function __construct(public readonly array $files, public readonly array $sizes, public readonly int $bytes) + { + } + + public static function of(string $staging): self + { + $files = []; + $sizes = []; + $bytes = 0; + + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($staging, FilesystemIterator::SKIP_DOTS)); + foreach ($iterator as $file) { + $relative = substr(str_replace('\\', '/', $file->getPathname()), strlen($staging) + 1); + $files[$relative] = $file->getPathname(); + $top = explode('/', $relative)[0] . (str_contains($relative, '/') ? '/' : ''); + $sizes[$top] = ($sizes[$top] ?? 0) + $file->getSize(); + $bytes += $file->getSize(); + } + ksort($files, SORT_STRING); + arsort($sizes); + + return new self($files, $sizes, $bytes); + } +} diff --git a/packages/build/src/ZipPackager.php b/packages/build/src/ZipPackager.php new file mode 100644 index 0000000..2d87b77 --- /dev/null +++ b/packages/build/src/ZipPackager.php @@ -0,0 +1,63 @@ +staging); + + $archive = new ZipArchive(); + if ($archive->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new BuildException(sprintf('Cannot write %s.', $zipPath)); + } + + // A single folder named after the slug, the way WordPress expects plugin/theme zips. + // Fixed timestamps and permissions: the same sources produce the same bytes, so a checksum + // proves what was released. libzip also reads the file's own mtime, hence the touch(). + $prefix = $context->project->slug . '/'; + foreach ($staged->files as $relative => $absolute) { + $name = $prefix . $relative; + touch($absolute, self::FIXED_MTIME); + $archive->addFile($absolute, $name); + $archive->setMtimeName($name, self::FIXED_MTIME); + $archive->setExternalAttributesName($name, ZipArchive::OPSYS_UNIX, 0100644 << 16); + } + $archive->close(); + + $sha256 = (string) hash_file('sha256', $zipPath); + file_put_contents($zipPath . '.sha256', $sha256 . ' ' . basename($zipPath) . PHP_EOL); + + return new BuildReport( + $zipPath, + $sha256, + $context->version, + $staged->bytes, + array_map(static fn (string $f): string => $prefix . $f, array_keys($staged->files)), + $staged->sizes, + $context->warnings + ); + } +} diff --git a/tests/E2E/build-fixtures.php b/tests/E2E/build-fixtures.php index dcc282d..99fa7aa 100644 --- a/tests/E2E/build-fixtures.php +++ b/tests/E2E/build-fixtures.php @@ -147,20 +147,14 @@ $main = (string) file_get_contents($source . '/todo.php'); file_put_contents($source . '/todo.php', str_replace('WPToolkit Todo example', 'WPToolkit Todo example (scoped build)', $main)); -$zip = dirname($source) . '/scoped.zip'; +// Straight into the folder wp-env mounts: the same steps and verification as a zip. \Codad5\WPToolkit\Build\Build::plugin($source) ->scope('WptkTodo\\Lib\\WPToolkit') ->withOutput(static function (string $line): void { fwrite(STDOUT, ' ' . $line . "\n"); }) - ->zip($zip); + ->toDirectory($build . '/wptk-todo-scoped'); -$archive = new ZipArchive(); -if ($archive->open($zip) !== true || !$archive->extractTo($build)) { - fwrite(STDERR, "Could not unpack {$zip}\n"); - exit(1); -} -$archive->close(); remove_directory(dirname($source)); fwrite(STDOUT, "built wptk-todo-scoped (examples/todo, packaged with Build::scope())\n"); diff --git a/tests/Unit/Build/DirectoryAndQuietTest.php b/tests/Unit/Build/DirectoryAndQuietTest.php new file mode 100644 index 0000000..5eb7f00 --- /dev/null +++ b/tests/Unit/Build/DirectoryAndQuietTest.php @@ -0,0 +1,263 @@ + */ + private array $lines = []; + + protected function setUp(): void + { + $this->root = sys_get_temp_dir() . '/wptoolkit-dir-' . bin2hex(random_bytes(4)); + $this->project = $this->root . '/my-plugin'; + mkdir($this->project . '/src', 0777, true); + mkdir($this->project . '/tests', 0777, true); + file_put_contents($this->project . '/my-plugin.php', "project . '/src/Plugin.php', 'project . '/tests/PluginTest.php', 'project . '/composer.json', '{}'); + $this->lines = []; + } + + protected function tearDown(): void + { + $items = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($this->root, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($items as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($this->root); + } + + // --- toDirectory() ------------------------------------------------------------------------- + + public function test_a_directory_build_holds_exactly_what_a_zip_would(): void + { + $zip = $this->build()->zip('dist/{slug}.zip'); + $report = $this->build()->toDirectory('dist/{slug}-{version}'); + + $directory = $this->project . '/dist/my-plugin-1.2.0'; + self::assertSame(str_replace('\\', '/', $directory), $report->directory); + self::assertFileExists($directory . '/my-plugin.php'); + self::assertFileExists($directory . '/src/Plugin.php'); + self::assertFileDoesNotExist($directory . '/tests/PluginTest.php', 'default excludes apply'); + self::assertFileDoesNotExist($directory . '/composer.json'); + self::assertDirectoryDoesNotExist($directory . '/dist', 'earlier output is not packaged into new output'); + + self::assertSame(['my-plugin.php', 'src/Plugin.php'], $report->files); + self::assertSame( + array_map(static fn (string $f): string => substr($f, strlen('my-plugin/')), $zip->files), + $report->files, + 'the same files as the zip, without its top-level folder' + ); + self::assertSame('', $report->zip); + self::assertSame('', $report->sha256); + self::assertStringContainsString('my-plugin-1.2.0/', $report->summary()); + self::assertStringNotContainsString('sha256', $report->summary()); + } + + public function test_commands_and_verification_run_as_they_do_for_a_zip(): void + { + file_put_contents($this->project . '/my-plugin.php', "project . '/package.json', '{"version": "1.2.0"}'); + + try { + $this->build()->version(\Codad5\WPToolkit\Build\Version::fromPackageJson())->toDirectory('out'); + self::fail('A stale Version header must fail verification.'); + } catch (BuildException $e) { + self::assertStringContainsString('Version header', $e->getMessage()); + } + self::assertDirectoryDoesNotExist($this->project . '/out', 'nothing is written when verification fails'); + } + + public function test_a_previous_build_of_the_same_project_is_replaced_not_merged(): void + { + $this->build()->toDirectory('out'); + file_put_contents($this->project . '/out/stale-from-last-build.php', 'project . '/src/Plugin.php'); + + $this->build()->toDirectory('out'); + + self::assertFileDoesNotExist($this->project . '/out/stale-from-last-build.php'); + self::assertFileDoesNotExist($this->project . '/out/src/Plugin.php'); + self::assertFileExists($this->project . '/out/my-plugin.php'); + } + + public function test_an_empty_existing_directory_is_fine(): void + { + mkdir($this->root . '/empty'); + + $this->build()->toDirectory($this->root . '/empty'); + + self::assertFileExists($this->root . '/empty/my-plugin.php'); + } + + public function test_a_directory_holding_something_else_is_never_overwritten(): void + { + mkdir($this->root . '/photos'); + file_put_contents($this->root . '/photos/holiday.jpg', 'precious'); + + try { + $this->build()->toDirectory($this->root . '/photos'); + self::fail('Expected a refusal.'); + } catch (BuildException $e) { + self::assertStringContainsString('not empty', $e->getMessage()); + } + self::assertSame('precious', file_get_contents($this->root . '/photos/holiday.jpg')); + self::assertFileDoesNotExist($this->root . '/photos/my-plugin.php'); + } + + /** + * @dataProvider dangerousTargets + */ + public function test_the_project_itself_and_its_parents_cannot_be_the_target(string $target): void + { + $before = file_get_contents($this->project . '/src/Plugin.php'); + + try { + $this->build()->toDirectory($target === 'PARENT' ? $this->root : $target); + self::fail('Expected a refusal.'); + } catch (BuildException $e) { + self::assertStringContainsString('project', $e->getMessage()); + } + self::assertSame($before, file_get_contents($this->project . '/src/Plugin.php')); + self::assertFileExists($this->project . '/tests/PluginTest.php'); + } + + /** + * @return array + */ + public static function dangerousTargets(): array + { + return [ + 'the project' => ['.'], + 'the project, spelled another way' => ['dist/..'], + 'the folder above, by ..' => ['..'], + 'the folder above the project' => ['PARENT'], + ]; + } + + public function test_the_project_path_in_another_letter_case_is_still_the_project(): void + { + // One folder on Windows and macOS; the project's own main file would otherwise make it look + // like an earlier build, and it would be emptied. + try { + $this->build()->toDirectory(strtoupper($this->project)); + self::fail('Expected a refusal.'); + } catch (BuildException $e) { + self::assertStringContainsString('project', $e->getMessage()); + } + self::assertFileExists($this->project . '/src/Plugin.php'); + self::assertFileExists($this->project . '/tests/PluginTest.php'); + } + + public function test_a_source_folder_of_the_project_is_never_overwritten(): void + { + try { + $this->build()->toDirectory('src'); + self::fail('Expected a refusal.'); + } catch (BuildException $e) { + self::assertStringContainsString('not empty', $e->getMessage()); + } + self::assertSame('project . '/src/Plugin.php')); + } + + public function test_the_working_tree_is_not_modified(): void + { + $before = $this->snapshot($this->project); + + $this->build()->toDirectory($this->root . '/elsewhere'); + + self::assertSame($before, $this->snapshot($this->project)); + } + + public function test_a_built_directory_can_be_scoped_and_checked_like_a_zip(): void + { + // The step list is shared: scope() without a bundled toolkit fails the same way for both. + $this->expectException(BuildException::class); + $this->expectExceptionMessage('No bundled WPToolkit found'); + $this->build()->step(new ScopeToolkit('Acme\\WPToolkit'))->toDirectory('out'); + } + + // --- quiet() ------------------------------------------------------------------------------- + + public function test_quiet_prints_neither_progress_nor_the_summary_but_still_returns_the_report(): void + { + $report = $this->build()->quiet()->zip('dist/{slug}.zip'); + + self::assertSame([], $this->lines); + self::assertCount(2, $report->files); + self::assertNotSame('', $report->sha256); + } + + public function test_quiet_still_prints_warnings(): void + { + $warns = new class implements \Codad5\WPToolkit\Build\BuildStep { + public function name(): string + { + return 'warns'; + } + + public function phase(): \Codad5\WPToolkit\Build\Phase + { + return \Codad5\WPToolkit\Build\Phase::Transform; + } + + public function run(\Codad5\WPToolkit\Build\BuildContext $context): void + { + $context->say('routine progress'); + $context->warn('something a person should read'); + } + }; + + $report = $this->build()->quiet()->step($warns)->zip('dist/{slug}.zip'); + + self::assertCount(1, $this->lines); + self::assertStringContainsString('something a person should read', $this->lines[0]); + self::assertSame(['something a person should read'], $report->warnings); + } + + public function test_quiet_wins_over_verbose(): void + { + $this->build()->quiet()->verbose()->run(escapeshellarg(PHP_BINARY) . ' -v')->zip('dist/{slug}.zip'); + + self::assertSame([], $this->lines); + } + + private function build(): Build + { + return Build::plugin($this->project)->withOutput(function (string $line): void { + $this->lines[] = $line; + }); + } + + /** + * @return array + */ + private function snapshot(string $directory): array + { + $files = []; + foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS)) as $file) { + $files[str_replace('\\', '/', substr($file->getPathname(), strlen($directory)))] = (string) md5_file($file->getPathname()); + } + ksort($files); + + return $files; + } +}