diff --git a/docs/adr/0026-builds-lock-write-atomically-and-clean-up-after-themselves.md b/docs/adr/0026-builds-lock-write-atomically-and-clean-up-after-themselves.md new file mode 100644 index 0000000..edce3bb --- /dev/null +++ b/docs/adr/0026-builds-lock-write-atomically-and-clean-up-after-themselves.md @@ -0,0 +1,64 @@ +# ADR-0026 — Builds take a lock, write atomically and clean up after themselves + +- **Status:** Accepted +- **Date:** 2026-10-12 +- **Deciders:** codad5 +- **Affects:** `packages/build` (`BuildLock`, `Interruption`, `Progress`, `ZipPackager`, `DirectoryPackager`, `ProcessCommandRunner`) + +## Context + +The packager copied the project, ran commands, and wrote the zip directly to its final name. Three +things went wrong, or could: + +- Two builds of one project (two CI jobs sharing a workspace, a watcher firing during a build) ran + `npm run build` in the same folder and wrote the same output; a reader could pick up a half-written zip. +- A build killed part-way (Ctrl+C, a CI timeout) left its temporary copy behind, and possibly a partial + zip or a half-emptied output folder that looked like a real one. A command it had started could keep + running. +- A slow step printed nothing, so a two-minute asset build looked hung. + +## Decision + +1. **One build per project at a time.** An operating-system file lock (`flock`) on a file in the system + temp directory, named from the project's resolved path. The system releases it when the process + ends however it ends, so there are no stale locks to clear. A second build fails at once saying + which process holds it, or waits (`waitForLock()`, `--wait`). The lock is outside the project so it + is never shipped or committed. +2. **Output is atomic.** A zip is written next to its target under a temporary name and renamed into + place when complete, then its checksum; a directory is built beside its target and swapped in, with + the previous one put back if the swap fails. A failed or stopped build leaves the previous output + whole. +3. **Interruption is cooperative.** SIGINT/SIGTERM (pcntl) or Ctrl+C (Windows) set a flag; the build + checks it between steps, several times a second while a command runs, and before each file it + writes. It stops by throwing, so the same cleanup runs as for any failure: the command and its + children are terminated (`taskkill /T` on Windows, `pkill -P` elsewhere), staging and partial output + are removed, the lock is released. Staging copies older than six hours left by builds that were + killed outright are swept at the start of the next build; recent ones may belong to a running build. +4. **Commands are watched, not blocked on.** The runner polls a temporary file the shell redirects the + command's output into, so it can call back several times a second without a non-blocking pipe, which + PHP cannot provide on Windows. The shell does the redirect, not a descriptor passed to `proc_open`: in + a long test run the descriptor form lost the child's output after certain earlier tests, for a cause + that was not identified. +5. **Progress is plain lines everywhere, plus a spinner on a real terminal.** Every step ends with its + duration. The spinner is drawn in place and erased when its step ends, so the screen shows what a log + would. It exists only when stdout is an interactive terminal and none of `CI`, `NO_COLOR`, + `TERM=dumb`, `quiet()`, `plain()` or a caller-supplied `withOutput()` handler applies, and on Windows + only if virtual-terminal sequences can be enabled. A command whose output is streamed shows that + output instead. + +## Options considered + +- **No lock; document "don't run two builds".** Cheap, but the failure is silent corruption. +- **A lock file with a PID inside.** Needs stale-lock detection, which is platform-specific and racy; + `flock` gets this for free. +- **Handle signals only with `pcntl`.** Leaves Windows, where most of this project's authors work, with + nothing; Windows has its own control handler. +- **A spinner library.** A dependency for forty lines of drawing, and it would not know when to stay + plain. + +## Consequences + +- A build that is killed with SIGKILL or a power cut still can't clean up; the sweep and the atomic + writes bound the damage to a temporary directory and a temporary file next to the output. +- `toDirectory()` needs room for two copies of the output while it swaps. +- Tests exercise real processes, real locks and, on Linux, a real SIGINT. diff --git a/docs/adr/0027-multiple-choice-fields-render-as-checkboxes-with-an-optional-picker.md b/docs/adr/0027-multiple-choice-fields-render-as-checkboxes-with-an-optional-picker.md new file mode 100644 index 0000000..4e08c88 --- /dev/null +++ b/docs/adr/0027-multiple-choice-fields-render-as-checkboxes-with-an-optional-picker.md @@ -0,0 +1,48 @@ +# ADR-0027 — Multiple-choice fields render as checkboxes, with an optional chip picker + +- **Status:** Accepted +- **Date:** 2026-10-12 +- **Deciders:** codad5 +- **Affects:** `Data\Field\Types\ChoiceType`, `ChoicePickerAssets`, `Field::display()`, `FieldValidator`, `resources/js/picker.js`, `resources/css/picker.css` + +## Context + +A `select` field with `->multiple()` rendered the browser's `` and add Chosen/Select2.** jQuery and a large third-party bundle per + consumer, and it stays a hidden select behind a custom widget. +- **Only a checkbox list.** Fine for six options, unusable for the 100-coin lists real plugins have. +- **A fully custom ARIA combobox.** More code and more accessibility risk than reusing real checkboxes. + +## Consequences + +- Existing multiple selects change appearance on upgrade; nothing stored changes. `display('native')` + restores the old look. +- A rule on a multiple field applies to each value, not to how many were chosen, so "at most 7" must be + enforced by the consumer. A count rule is a candidate for a later change. +- The `media` field still refers to an admin script that does not exist; unrelated to this change. diff --git a/docs/adr/README.md b/docs/adr/README.md index 5898a92..ff98e28 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -58,4 +58,6 @@ Not for a naming choice, a library with no real alternative, or anything you wou | [0022](0022-shortcodes-are-declared-validated-and-lazy.md) | Shortcodes are declared, validated, access-checked and load assets lazily | Accepted | | [0023](0023-settings-may-keep-a-legacy-option-prefix.md) | Settings may keep an option prefix that already exists in users' data | Accepted | | [0024](0024-the-packager-scopes-the-consumers-code-too.md) | The packager scopes the consumer's code and autoloader, not only the library | Accepted | +| [0027](0027-multiple-choice-fields-render-as-checkboxes-with-an-optional-picker.md) | Multiple-choice fields are checkboxes, with an optional chip picker | Accepted | +| [0026](0026-builds-lock-write-atomically-and-clean-up-after-themselves.md) | Builds take a lock, write atomically and clean up after themselves | Accepted | | [0025](0025-the-guard-can-autoload-the-plugins-own-namespaces.md) | The guard can autoload the plugin's own namespaces (completes ADR-0014) | Accepted | diff --git a/docs/guides/data.md b/docs/guides/data.md index 4ebc015..3655250 100644 --- a/docs/guides/data.md +++ b/docs/guides/data.md @@ -20,6 +20,22 @@ Types: `text`, `textarea`, `email`, `url`, `tel`, `number`, `date`, `color`, `hi the library: implement `FieldType` (or `wp {slug} make:field StarRating`) and `$fieldTypes->register('stars', new StarRatingFieldType())`. +### Choosing several values + +`$f->select('labels', $options)->multiple()` renders as **real checkboxes**, not the browser's +``. +14. Change stored data with a `Migration` registered via `->migrations([...])`, never with branches on a "data version". ## Core API diff --git a/packages/build/bin/wptoolkit-build b/packages/build/bin/wptoolkit-build index a2b2b6b..eab3db7 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 | --dir=PATH] [--dry-run] [--verbose] [--quiet] + * vendor/bin/wptoolkit-build package [--project=DIR] [--zip=PATH | --dir=PATH] [--dry-run] [--verbose] [--quiet] [--plain] [--wait=SECONDS] * vendor/bin/wptoolkit-build verify FILE.zip * vendor/bin/wptoolkit-build doctor [--project=DIR] * vendor/bin/wptoolkit-build init [--project=DIR] @@ -63,6 +63,12 @@ try { if (in_array('--quiet', $argv, true)) { $build->quiet(); } + if (in_array('--plain', $argv, true)) { + $build->plain(); + } + if (isset($options['wait'])) { + $build->waitForLock((int) $options['wait']); + } if (isset($options['dir'])) { $build->toDirectory($options['dir']); exit(0); @@ -108,10 +114,12 @@ try { default: fwrite(STDOUT, "Usage:\n" - . " wptoolkit-build package [--project=DIR] [--zip=PATH | --dir=PATH] [--dry-run] [--verbose] [--quiet]\n" + . " wptoolkit-build package [--project=DIR] [--zip=PATH | --dir=PATH] [--dry-run] [--verbose] [--quiet] [--plain] [--wait=SECONDS]\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" + . " --plain no spinner, even on an interactive terminal\n" + . " --wait=N if another build of this project is running, wait up to N seconds\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/BackgroundCommandRunner.php b/packages/build/src/BackgroundCommandRunner.php new file mode 100644 index 0000000..3ab7be8 --- /dev/null +++ b/packages/build/src/BackgroundCommandRunner.php @@ -0,0 +1,29 @@ + */ private array $excludes = []; @@ -150,6 +158,38 @@ public function verbose(bool $verbose = true): self return $this; } + /** + * Plain lines only: no spinner, even on an interactive terminal. (Output that isn't a terminal + * — a file, a pipe, CI — and a build with its own withOutput() handler are always plain.) + */ + public function plain(bool $plain = true): self + { + $this->plain = $plain; + + return $this; + } + + /** + * If another build of this project is running, wait up to this long for it instead of failing + * at once. + */ + public function waitForLock(int $seconds): self + { + $this->waitForLock = max(0, $seconds); + + return $this; + } + + /** + * Stop the build on your own condition (`Interruption::when(...)`), in addition to Ctrl+C. + */ + public function withInterruption(Interruption $interruption): self + { + $this->interruption = $interruption; + + 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(). @@ -244,6 +284,7 @@ public function withRunner(CommandRunner $runner): self public function withOutput(Closure $output): self { $this->output = $output; + $this->ownOutput = true; return $this; } @@ -301,12 +342,20 @@ private function resolveVersion(): string */ private function produce(string $version, array $notStaged, Closure $write): BuildReport { + $interruption = $this->interruption ?? new Interruption(); + $progress = $this->progress(); $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)); + $context = new BuildContext($this->project, $staging, $version, $this->runner, $this->output, $this->verbose, $this->quiet, $progress, $interruption); + $interruption->install(); + $lock = null; try { + $lock = $this->lock($context, $interruption); + self::sweepAbandonedStaging(); + $context->say(sprintf('Building %s %s (%s)', $this->project->slug, $version, $this->project->type)); + $this->runPhase(Phase::Prepare, $context); + $context->checkpoint(); $this->stage($staging, $notStaged); $this->runPhase(Phase::Transform, $context); $this->prune($staging); @@ -318,6 +367,62 @@ private function produce(string $version, array $notStaged, Closure $write): Bui return $report; } finally { self::removeDirectory(dirname($staging)); + $lock?->release(); + $interruption->uninstall(); + } + } + + /** + * One build per project at a time: a second one would run its commands in the same folder and + * write the same output. + */ + private function lock(BuildContext $context, Interruption $interruption): BuildLock + { + try { + return BuildLock::acquire($this->project); + } catch (BuildException $busy) { + if ($this->waitForLock === 0) { + throw $busy; + } + } + + $context->say(sprintf('Another build of this project is running; waiting up to %d seconds…', $this->waitForLock)); + + return BuildLock::acquire($this->project, $this->waitForLock, static fn () => $interruption->check()); + } + + /** + * A spinner only where it can be drawn and erased: our own output, on an interactive terminal. + */ + private function progress(): Progress + { + $interactive = !$this->plain && !$this->quiet && !$this->ownOutput + && defined('STDOUT') && stream_isatty(STDOUT) + && getenv('CI') === false && getenv('NO_COLOR') === false && getenv('TERM') !== 'dumb' + // Windows consoles need escape-sequence processing switched on; without it, stay plain. + && (PHP_OS_FAMILY !== 'Windows' || (function_exists('sapi_windows_vt100_support') && sapi_windows_vt100_support(STDOUT, true))); + + // Braille spinner frames don't render in the legacy Windows console; Windows Terminal is fine. + $unicode = PHP_OS_FAMILY !== 'Windows' || getenv('WT_SESSION') !== false; + + return new Progress($this->output, $interactive ? STDOUT : null, $unicode, $this->quiet); + } + + /** + * Staging copies left in the temp directory by builds that were killed before they could clean + * up. Only old ones: a recent one may belong to a build that is running now. + */ + private static function sweepAbandonedStaging(): void + { + $olderThan = time() - 6 * 3600; + $found = array_merge( + glob(sys_get_temp_dir() . '/wptoolkit-build-*', GLOB_ONLYDIR) ?: [], + glob(sys_get_temp_dir() . '/wptoolkit-dryrun-*', GLOB_ONLYDIR) ?: [] + ); + foreach ($found as $directory) { + if (preg_match('/wptoolkit-(build|dryrun)-[0-9a-f]{12}$/', $directory) === 1 && (int) @filemtime($directory) < $olderThan) { + self::removeDirectory($directory); + } } } @@ -363,12 +468,16 @@ private function runPhase(Phase $phase, BuildContext $context, array $extra = [] continue; } - $context->say('→ ' . $step->name()); + $context->checkpoint(); + $context->progress()->stepStarted($step->name()); try { $step->run($context); + $context->progress()->stepFinished(); } catch (BuildException $failure) { + $context->progress()->stepFailed(); throw $failure; } catch (\Throwable $error) { + $context->progress()->stepFailed(); throw BuildException::inStep($step->name(), $error->getMessage()); } } diff --git a/packages/build/src/BuildContext.php b/packages/build/src/BuildContext.php index 663a50e..5242113 100644 --- a/packages/build/src/BuildContext.php +++ b/packages/build/src/BuildContext.php @@ -15,26 +15,33 @@ */ final class BuildContext { + /** How much of a streamed command's output a failure repeats: it was already printed once. */ + private const FAILURE_TAIL = 20; + /** @var list */ public array $warnings = []; + private readonly Progress $progress; + /** * @param Closure(string): void $output + * @param Progress|null $progress How output is drawn; plain lines to `$output` when omitted. + * @param Interruption|null $interruption Checked at safe points; the build stops when it fires. */ public function __construct( public readonly Project $project, public readonly string $staging, public readonly string $version, public readonly CommandRunner $runner, - private readonly Closure $output, + Closure $output, public readonly bool $verbose = false, - public readonly bool $quiet = false + public readonly bool $quiet = false, + ?Progress $progress = null, + private readonly ?Interruption $interruption = null ) { + $this->progress = $progress ?? new Progress($output, quiet: $quiet); } - /** How much of a streamed command's output a failure repeats: it was already printed once. */ - private const FAILURE_TAIL = 20; - /** * Run a shell command, failing the step on a non-zero exit code. * @@ -48,14 +55,12 @@ public function exec(string $step, string $command, string $workingDirectory, ?b $print = function (string $line): void { $this->say(' | ' . $line); }; - if ($stream && $this->runner instanceof StreamingCommandRunner) { - $result = $this->runner->stream($command, $workingDirectory, $print); - } else { - $result = $this->runner->run($command, $workingDirectory); - if ($stream) { - // This runner can't stream: show the output now that the command has ended. - array_map($print, self::lines($result['output'])); - } + // A streamed command's own lines are the progress; a silent one gets the spinner. + $this->progress->activity($stream ? null : $command); + try { + $result = $this->run($command, $workingDirectory, $stream, $print); + } finally { + $this->progress->activity(null); } if ($result['exitCode'] !== 0) { @@ -75,13 +80,20 @@ public function exec(string $step, string $command, string $workingDirectory, ?b } /** - * @return list + * A safe point: stop here if the build was interrupted, and keep the spinner moving. Long loops + * in a step (one iteration per file, say) should call it. + * + * @throws BuildException When the build has been asked to stop. */ - private static function lines(string $output): array + public function checkpoint(): void { - $output = trim(str_replace("\r\n", "\n", $output)); + $this->interruption?->check(); + $this->progress->tick(); + } - return $output === '' ? [] : explode("\n", $output); + public function progress(): Progress + { + return $this->progress; } /** @@ -89,9 +101,7 @@ private static function lines(string $output): array */ public function say(string $line): void { - if (!$this->quiet) { - ($this->output)($line); - } + $this->progress->line($line); } /** @@ -100,7 +110,7 @@ public function say(string $line): void public function warn(string $line): void { $this->warnings[] = $line; - ($this->output)(' ! ' . $line); + $this->progress->warn($line); } /** @@ -110,4 +120,40 @@ public function staged(string $relative = ''): string { return $relative === '' ? $this->staging : $this->staging . '/' . ltrim($relative, '/'); } + + /** + * @param Closure(string): void $print + * @return array{exitCode: int, output: string} + */ + private function run(string $command, string $workingDirectory, bool $stream, Closure $print): array + { + $quietly = static function (string $line): void { + }; + + if ($this->runner instanceof BackgroundCommandRunner) { + return $this->runner->watch($command, $workingDirectory, $stream ? $print : $quietly, fn () => $this->checkpoint()); + } + if ($stream && $this->runner instanceof StreamingCommandRunner) { + return $this->runner->stream($command, $workingDirectory, $print); + } + + $result = $this->runner->run($command, $workingDirectory); + if ($stream) { + // This runner can't stream: show the output now that the command has ended. + array_map($print, self::lines($result['output'])); + } + $this->checkpoint(); + + return $result; + } + + /** + * @return list + */ + private static function lines(string $output): array + { + $output = trim(str_replace("\r\n", "\n", $output)); + + return $output === '' ? [] : explode("\n", $output); + } } diff --git a/packages/build/src/BuildLock.php b/packages/build/src/BuildLock.php new file mode 100644 index 0000000..6dc963d --- /dev/null +++ b/packages/build/src/BuildLock.php @@ -0,0 +1,109 @@ +root) ?: $project->root))); + + return sys_get_temp_dir() . '/wptoolkit-build-locks/' . sha1($root) . '.lock'; + } + + /** + * @param int $waitSeconds 0 fails at once if another build holds the lock. + * @param (Closure(): void)|null $whileWaiting Called between attempts (to notice an interruption). + * @throws BuildException When the lock can't be taken in time. + */ + public static function acquire(Project $project, int $waitSeconds = 0, ?Closure $whileWaiting = null): self + { + $file = self::path($project); + if (!is_dir(dirname($file)) && !mkdir(dirname($file), 0777, true) && !is_dir(dirname($file))) { + throw new BuildException(sprintf('Cannot create %s for the build lock.', dirname($file))); + } + + $handle = fopen($file, 'c'); + if ($handle === false) { + throw new BuildException(sprintf('Cannot open the build lock %s.', $file)); + } + + $deadline = microtime(true) + $waitSeconds; + while (!flock($handle, LOCK_EX | LOCK_NB)) { + if (microtime(true) >= $deadline) { + fclose($handle); + throw new BuildException(self::busyMessage($file, $waitSeconds)); + } + if ($whileWaiting !== null) { + try { + $whileWaiting(); + } catch (\Throwable $stop) { + fclose($handle); + throw $stop; + } + } + usleep((int) (self::RETRY_EVERY * 1_000_000)); + } + + // Who holds it, for the other build's message. A separate file: on Windows a locked file + // can't be read by another process. + file_put_contents($file . '.json', (string) json_encode(['pid' => getmypid(), 'started' => date('H:i:s')])); + + return new self($handle, $file); + } + + public function release(): void + { + if (!is_resource($this->handle)) { + return; + } + + @unlink($this->file . '.json'); + flock($this->handle, LOCK_UN); + fclose($this->handle); + $this->handle = null; + } + + private static function busyMessage(string $file, int $waited): string + { + $info = is_file($file . '.json') ? json_decode((string) file_get_contents($file . '.json'), true) : null; + $who = is_array($info) && isset($info['pid'], $info['started']) + ? sprintf(' (process %d, started at %s)', (int) $info['pid'], (string) $info['started']) + : ''; + + return sprintf( + 'Another build of this project is running%s%s. Wait for it to finish, or use waitForLock() / --wait=SECONDS.', + $who, + $waited > 0 ? sprintf(' and did not finish within %d seconds', $waited) : '' + ); + } +} diff --git a/packages/build/src/DirectoryPackager.php b/packages/build/src/DirectoryPackager.php index 0b75415..a0fa2c5 100644 --- a/packages/build/src/DirectoryPackager.php +++ b/packages/build/src/DirectoryPackager.php @@ -11,6 +11,7 @@ use FilesystemIterator; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; +use Throwable; /** * Writes a staged project as a folder (`Build::toDirectory()`): for rsync or SVN deploys, or to @@ -66,21 +67,58 @@ public function write(BuildContext $context, string $target): BuildReport $target = self::normalize($target); $this->assertSafe($context->project, $target); - self::empty($target); + // Written beside the target and swapped in when complete: a build that fails or is stopped + // while copying leaves the previous folder as it was. + $suffix = bin2hex(random_bytes(4)); + $incoming = $target . '.tmp-' . $suffix; + $outgoing = $target . '.old-' . $suffix; $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)); + + try { + self::makeDirectory($incoming); + foreach ($staged->files as $relative => $absolute) { + $context->checkpoint(); + $destination = $incoming . '/' . $relative; + self::makeDirectory(dirname($destination)); + if (!copy($absolute, $destination)) { + throw new BuildException(sprintf('Cannot write %s.', $destination)); + } } + $this->swap($target, $incoming, $outgoing); + } catch (Throwable $failure) { + self::remove($incoming); + throw $failure; } return new BuildReport('', '', $context->version, $staged->bytes, array_keys($staged->files), $staged->sizes, $context->warnings, $target); } + /** + * Put the finished folder in place of the previous one, and put the previous one back if that + * can't be done (on Windows a folder with a file open in another program can't be renamed). + */ + private function swap(string $target, string $incoming, string $outgoing): void + { + $hadPrevious = is_dir($target); + if ($hadPrevious && !@rename($target, $outgoing)) { + throw new BuildException(sprintf('Cannot replace %s (is a file in it open in another program?). It was left as it was.', $target)); + } + if (!@rename($incoming, $target)) { + if ($hadPrevious) { + @rename($outgoing, $target); + } + throw new BuildException(sprintf('Cannot write %s. It was left as it was.', $target)); + } + self::remove($outgoing); + } + + private static function makeDirectory(string $directory): void + { + if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { + throw new BuildException(sprintf('Cannot create %s.', $directory)); + } + } + /** * 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. @@ -104,10 +142,9 @@ public static function normalize(string $path): string return rtrim($prefix . implode('/', $segments), '/'); } - private static function empty(string $directory): void + private static function remove(string $directory): void { if (!is_dir($directory)) { - mkdir($directory, 0777, true); return; } @@ -116,7 +153,8 @@ private static function empty(string $directory): void RecursiveIteratorIterator::CHILD_FIRST ); foreach ($items as $item) { - $item->isDir() && !$item->isLink() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + $item->isDir() && !$item->isLink() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); } + @rmdir($directory); } } diff --git a/packages/build/src/Interruption.php b/packages/build/src/Interruption.php new file mode 100644 index 0000000..b047b2b --- /dev/null +++ b/packages/build/src/Interruption.php @@ -0,0 +1,107 @@ +condition = $condition; + + return $interruption; + } + + /** + * Start listening for Ctrl+C / termination. Safe to call where neither can be caught. + */ + public function install(): void + { + if ($this->installed) { + return; + } + $this->installed = true; + + if (function_exists('pcntl_async_signals') && function_exists('pcntl_signal')) { + pcntl_async_signals(true); + foreach ([SIGINT, SIGTERM] as $signal) { + pcntl_signal($signal, function (): void { + $this->trigger(); + }); + } + } elseif (function_exists('sapi_windows_set_ctrl_handler')) { + $this->windowsHandler = function (int $event): void { + $this->trigger(); + }; + sapi_windows_set_ctrl_handler($this->windowsHandler, true); + } + } + + public function uninstall(): void + { + if (!$this->installed) { + return; + } + $this->installed = false; + + if (function_exists('pcntl_signal')) { + foreach ([SIGINT, SIGTERM] as $signal) { + pcntl_signal($signal, SIG_DFL); + } + } elseif ($this->windowsHandler !== null && function_exists('sapi_windows_set_ctrl_handler')) { + sapi_windows_set_ctrl_handler($this->windowsHandler, false); + $this->windowsHandler = null; + } + } + + public function trigger(): void + { + $this->triggered = true; + } + + /** + * @throws BuildException When the build has been asked to stop. + */ + public function check(): void + { + if ($this->triggered || ($this->condition !== null && ($this->condition)())) { + $this->triggered = true; + throw new BuildException('Build interrupted. Nothing was written, and the temporary copy was removed.'); + } + } +} diff --git a/packages/build/src/JsonConfig.php b/packages/build/src/JsonConfig.php index f79dc7d..f3b8d54 100644 --- a/packages/build/src/JsonConfig.php +++ b/packages/build/src/JsonConfig.php @@ -53,6 +53,12 @@ public static function load(string $projectDirectory, string $file = 'wptoolkit. if (!empty($config['quiet'])) { $build->quiet(); } + if (($config['progress'] ?? true) === false) { + $build->plain(); + } + if (isset($config['waitForLock'])) { + $build->waitForLock((int) $config['waitForLock']); + } if (isset($config['version'])) { $build->version(self::versionSource((string) $config['version'])); } diff --git a/packages/build/src/ProcessCommandRunner.php b/packages/build/src/ProcessCommandRunner.php index d506cc0..b9c62d5 100644 --- a/packages/build/src/ProcessCommandRunner.php +++ b/packages/build/src/ProcessCommandRunner.php @@ -9,34 +9,118 @@ namespace Codad5\WPToolkit\Build; use Closure; +use Throwable; /** * Runs commands through the system shell with proc_open, capturing stdout and stderr together. + * + * The command writes to a temporary file that this class polls, rather than to a pipe: reading a + * pipe blocks until the command says something (and PHP can't make a child's pipe non-blocking on + * Windows), which would freeze the spinner and hide Ctrl+C for as long as the command is silent. */ -final class ProcessCommandRunner implements StreamingCommandRunner +final class ProcessCommandRunner implements BackgroundCommandRunner { + /** Microseconds between polls: short at first so quick commands stay quick, then relaxed. */ + private const POLL_FROM = 5_000; + private const POLL_UP_TO = 100_000; + public function run(string $command, string $workingDirectory): array { - return $this->stream($command, $workingDirectory, static function (string $line): void { + return $this->watch($command, $workingDirectory, static function (string $line): void { + }, static function (): void { }); } public function stream(string $command, string $workingDirectory, Closure $onLine): array { - // stderr joins stdout in the shell, so one pipe carries both and can't deadlock. - $process = proc_open($command . ' 2>&1', [1 => ['pipe', 'w']], $pipes, $workingDirectory); + return $this->watch($command, $workingDirectory, $onLine, static function (): void { + }); + } + + public function watch(string $command, string $workingDirectory, Closure $onLine, Closure $onTick): array + { + $log = tempnam(sys_get_temp_dir(), 'wptk'); + if ($log === false) { + return ['exitCode' => 127, 'output' => "Could not start: {$command} (no temporary file)"]; + } + // The shell does the redirecting (rather than a descriptor handed to proc_open), so it doesn't + // depend on which handles this process happens to have open. stderr joins stdout, so one file + // carries both in the order they were written. + $process = proc_open($command . ' > ' . escapeshellarg($log) . ' 2>&1', [], $pipes, $workingDirectory); if (!is_resource($process)) { + @unlink($log); return ['exitCode' => 127, 'output' => "Could not start: {$command}"]; } $output = ''; - while (($line = fgets($pipes[1])) !== false) { - $output .= $line; - $onLine(rtrim($line, "\r\n")); + $pending = ''; + $read = 0; + $drain = static function () use ($log, &$output, &$pending, &$read, $onLine): void { + $chunk = (string) file_get_contents($log, false, null, $read); + if ($chunk === '') { + return; + } + $read += strlen($chunk); + $output .= $chunk; + $lines = explode("\n", $pending . $chunk); + $pending = (string) array_pop($lines); + foreach ($lines as $line) { + $onLine(rtrim($line, "\r")); + } + }; + + $wait = self::POLL_FROM; + try { + do { + $status = proc_get_status($process); + $drain(); + if ($status['running']) { + $onTick(); + usleep($wait); + $wait = min(self::POLL_UP_TO, $wait * 2); + } + } while ($status['running']); + + $drain(); + if ($pending !== '') { + $onLine(rtrim($pending, "\r")); + } + + return ['exitCode' => (int) $status['exitcode'], 'output' => $output]; + } catch (Throwable $stop) { + self::terminate($process); + throw $stop; + } finally { + proc_close($process); + @unlink($log); + } + } + + /** + * Stop the command and what it started. proc_terminate() alone ends only the shell: on Windows + * `npm run build` would leave node running. + * + * @param resource $process + */ + private static function terminate(mixed $process): void + { + $status = proc_get_status($process); + if (!$status['running']) { + return; + } + + $pid = (int) $status['pid']; + if (PHP_OS_FAMILY === 'Windows') { + exec('taskkill /F /T /PID ' . $pid . ' 2>NUL'); + } else { + exec('pkill -TERM -P ' . $pid . ' 2>/dev/null'); } - fclose($pipes[1]); + proc_terminate($process); - return ['exitCode' => proc_close($process), 'output' => $output]; + // Give it a moment to go, so its files aren't still open when the caller cleans up. + for ($i = 0; $i < 50 && proc_get_status($process)['running']; $i++) { + usleep(20_000); + } } } diff --git a/packages/build/src/Progress.php b/packages/build/src/Progress.php new file mode 100644 index 0000000..ba35328 --- /dev/null +++ b/packages/build/src/Progress.php @@ -0,0 +1,174 @@ +clock = $clock ?? static fn (): float => microtime(true); + } + + public function line(string $line): void + { + if (!$this->quiet) { + $this->write($line); + } + } + + /** + * Printed even when quiet: something a person should read. + */ + public function warn(string $line): void + { + $this->write(' ! ' . $line); + } + + public function stepStarted(string $name): void + { + $this->stepStartedAt = ($this->clock)(); + $this->line('→ ' . $name); + } + + public function stepFinished(): void + { + $this->endStep('✓'); + } + + public function stepFailed(): void + { + $this->endStep('✗'); + } + + /** + * What the step is waiting on now (a command), or null when nothing should spin — including + * while a command's own output is being streamed, which is progress enough. + */ + public function activity(?string $label): void + { + $this->erase(); + $this->activity = $label; + $this->activityStartedAt = ($this->clock)(); + $this->draw(); + } + + /** + * Called several times a second while something runs. Does nothing without a terminal. + */ + public function tick(): void + { + if ($this->terminal === null || $this->activity === null || $this->quiet) { + return; + } + if (($this->clock)() - $this->lastDrawnAt >= self::REDRAW_EVERY) { + $this->frame++; + $this->draw(); + } + } + + public static function duration(float $seconds): string + { + if ($seconds < 60) { + return sprintf('%.1fs', $seconds); + } + + return sprintf('%dm %02ds', intdiv((int) $seconds, 60), (int) $seconds % 60); + } + + private function endStep(string $mark): void + { + $this->erase(); + $this->activity = null; + $this->line(' ' . $mark . ' ' . self::duration(($this->clock)() - $this->stepStartedAt)); + } + + private function write(string $line): void + { + if ($this->terminal === null) { + ($this->output)($line); + return; + } + + $this->erase(); + fwrite($this->terminal, $line . "\n"); + $this->draw(); + } + + private function draw(): void + { + if ($this->terminal === null || $this->activity === null || $this->quiet) { + return; + } + + $frames = $this->unicode ? self::FRAMES : self::ASCII_FRAMES; + $elapsed = (int) (($this->clock)() - $this->activityStartedAt); + $time = $elapsed < 60 ? $elapsed . 's' : sprintf('%dm %02ds', intdiv($elapsed, 60), $elapsed % 60); + + fwrite($this->terminal, self::ERASE_LINE . ' ' . $frames[$this->frame % count($frames)] . ' ' . $this->activity . ' ' . $time); + $this->spinnerShown = true; + $this->lastDrawnAt = ($this->clock)(); + } + + private function erase(): void + { + if ($this->spinnerShown && $this->terminal !== null) { + fwrite($this->terminal, self::ERASE_LINE); + $this->spinnerShown = false; + } + } +} diff --git a/packages/build/src/ZipPackager.php b/packages/build/src/ZipPackager.php index 2d87b77..48b2a5e 100644 --- a/packages/build/src/ZipPackager.php +++ b/packages/build/src/ZipPackager.php @@ -8,10 +8,15 @@ namespace Codad5\WPToolkit\Build; +use Throwable; use ZipArchive; /** * Writes a staged project as a reproducible zip with a `.sha256` next to it. + * + * The zip is written under a temporary name and renamed into place only when it is complete, so + * whatever reads the target sees the previous zip or the new one, never half of one — and a build + * that fails or is stopped while packaging leaves the previous zip untouched. */ final class ZipPackager { @@ -20,35 +25,33 @@ final class ZipPackager public function write(BuildContext $context, string $zipPath): BuildReport { - if (!is_dir(dirname($zipPath))) { - mkdir(dirname($zipPath), 0777, true); - } - if (is_file($zipPath)) { - unlink($zipPath); + if (!is_dir(dirname($zipPath)) && !mkdir(dirname($zipPath), 0777, true) && !is_dir(dirname($zipPath))) { + throw new BuildException(sprintf('Cannot create %s.', dirname($zipPath))); } $staged = StagedFiles::of($context->staging); + $prefix = $context->project->slug . '/'; + $suffix = '.tmp-' . bin2hex(random_bytes(4)); + $temporary = [$zipPath . $suffix, $zipPath . '.sha256' . $suffix]; - $archive = new ZipArchive(); - if ($archive->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { - throw new BuildException(sprintf('Cannot write %s.', $zipPath)); - } + try { + $this->archive($context, $staged, $prefix, $temporary[0]); - // 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', $temporary[0]); + file_put_contents($temporary[1], $sha256 . ' ' . basename($zipPath) . PHP_EOL); - $sha256 = (string) hash_file('sha256', $zipPath); - file_put_contents($zipPath . '.sha256', $sha256 . ' ' . basename($zipPath) . PHP_EOL); + // From here the output is complete; only the two renames are left. + if (!rename($temporary[0], $zipPath) || !rename($temporary[1], $zipPath . '.sha256')) { + throw new BuildException(sprintf('Cannot write %s (is it open in another program?).', $zipPath)); + } + } catch (Throwable $failure) { + foreach ($temporary as $file) { + if (is_file($file)) { + @unlink($file); + } + } + throw $failure; + } return new BuildReport( $zipPath, @@ -60,4 +63,36 @@ public function write(BuildContext $context, string $zipPath): BuildReport $context->warnings ); } + + private function archive(BuildContext $context, StagedFiles $staged, string $prefix, string $path): void + { + $archive = new ZipArchive(); + if ($archive->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new BuildException(sprintf('Cannot write %s.', $path)); + } + + // 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(). + try { + foreach ($staged->files as $relative => $absolute) { + $context->checkpoint(); + $name = $prefix . $relative; + touch($absolute, self::FIXED_MTIME); + if (!$archive->addFile($absolute, $name)) { + throw new BuildException(sprintf('Cannot add %s to the zip.', $relative)); + } + $archive->setMtimeName($name, self::FIXED_MTIME); + $archive->setExternalAttributesName($name, ZipArchive::OPSYS_UNIX, 0100644 << 16); + } + } catch (Throwable $failure) { + $archive->unchangeAll(); + @$archive->close(); + throw $failure; + } + + if (!$archive->close()) { + throw new BuildException(sprintf('Cannot finish writing %s.', $path)); + } + } } diff --git a/resources/css/picker.css b/resources/css/picker.css new file mode 100644 index 0000000..73b6185 --- /dev/null +++ b/resources/css/picker.css @@ -0,0 +1,40 @@ +/* WPToolkit multiple-choice picker. A plain checkbox list needs only the first rules. */ +.wptoolkit-picker { border: 0; margin: 0; padding: 0; min-width: 0; position: relative; } +.wptoolkit-picker > label, +.wptoolkit-picker__panel > label { display: block; margin: 0 0 4px; } + +.wptoolkit-picker[data-wptoolkit-ready] .wptoolkit-picker__control { + display: flex; flex-wrap: wrap; align-items: center; gap: 4px; box-sizing: border-box; + min-height: 30px; max-width: 25em; padding: 2px 6px; background: #fff; cursor: text; + border: 1px solid #8c8f94; border-radius: 4px; +} +.wptoolkit-picker.is-open .wptoolkit-picker__control { + border-color: var(--wp-admin-theme-color, #2271b1); + box-shadow: 0 0 0 1px var(--wp-admin-theme-color, #2271b1); +} +.wptoolkit-picker__chips { display: contents; list-style: none; margin: 0; padding: 0; } +.wptoolkit-picker__chip { + display: inline-flex; align-items: center; gap: 2px; margin: 0; padding: 1px 2px 1px 8px; + background: #f0f0f1; border: 1px solid #c3c4c7; border-radius: 3px; line-height: 1.6; +} +.wptoolkit-picker__remove { + all: unset; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; + width: 20px; height: 20px; border-radius: 3px; cursor: pointer; font-size: 16px; line-height: 1; color: #50575e; +} +.wptoolkit-picker__remove:hover { background: #dcdcde; color: #1d2327; } +.wptoolkit-picker__remove:focus-visible { outline: 2px solid var(--wp-admin-theme-color, #2271b1); outline-offset: 0; } +.wptoolkit-picker .wptoolkit-picker__search { + flex: 1 1 8em; min-width: 6em; margin: 0; padding: 0 2px; border: 0; background: transparent; box-shadow: none; min-height: 26px; +} +.wptoolkit-picker .wptoolkit-picker__search:focus { outline: 0; box-shadow: none; } + +.wptoolkit-picker[data-wptoolkit-ready] .wptoolkit-picker__panel { + display: none; position: absolute; inset-inline-start: 0; z-index: 1000; box-sizing: border-box; + width: 100%; max-width: 25em; max-height: 14em; overflow: auto; margin-top: 2px; padding: 4px 0; + background: #fff; border: 1px solid #8c8f94; border-radius: 4px; box-shadow: 0 3px 8px rgba(0, 0, 0, .15); +} +.wptoolkit-picker.is-open .wptoolkit-picker__panel { display: block; } +.wptoolkit-picker[data-wptoolkit-ready] .wptoolkit-picker__panel > label { margin: 0; padding: 4px 10px; } +.wptoolkit-picker[data-wptoolkit-ready] .wptoolkit-picker__panel > label:hover { background: #f0f6fc; } +.wptoolkit-picker__panel > label[hidden], .wptoolkit-picker__empty[hidden] { display: none; } +.wptoolkit-picker__empty { margin: 0; padding: 6px 10px; color: #646970; } diff --git a/resources/js/picker.js b/resources/js/picker.js new file mode 100644 index 0000000..2243681 --- /dev/null +++ b/resources/js/picker.js @@ -0,0 +1,141 @@ +/*! + * WPToolkit multiple-choice picker: turns a checkbox list into a searchable chip picker. + * + * The checkboxes stay in the page and stay the source of truth, so the form submits exactly what it + * would without this script, keyboard use works the same, and a failure here leaves a usable list. + * No library and no jQuery. Plugins that bundle different copies of WPToolkit each print this file; + * an element is enhanced by whichever runs first. + */ +(function () { + 'use strict'; + + var READY = 'data-wptoolkit-ready'; + + function make(tag, className, attributes) { + var node = document.createElement(tag); + if (className) { node.className = className; } + Object.keys(attributes || {}).forEach(function (name) { node.setAttribute(name, attributes[name]); }); + return node; + } + + function toggle(box, checked) { + box.checked = checked; + box.dispatchEvent(new Event('change', { bubbles: true })); + } + + function enhance(root) { + if (root.getAttribute('data-wptoolkit-picker') !== 'tokens' || root.hasAttribute(READY)) { return; } + var boxes = [].slice.call(root.querySelectorAll('input[type="checkbox"]')); + if (!boxes.length) { return; } + root.setAttribute(READY, '1'); + + var labels = { + search: root.getAttribute('data-label-search') || 'Search…', + remove: root.getAttribute('data-label-remove') || 'Remove %s', + none: root.getAttribute('data-label-none') || 'No matches' + }; + var options = boxes.map(function (box) { + var row = box.closest('label'); + return { box: box, row: row, text: (row.textContent || '').trim() }; + }); + + var control = make('div', 'wptoolkit-picker__control'); + var chips = make('ul', 'wptoolkit-picker__chips', { 'aria-live': 'polite' }); + var search = make('input', 'wptoolkit-picker__search', { + type: 'text', autocomplete: 'off', placeholder: labels.search, 'aria-label': labels.search + }); + var panel = make('div', 'wptoolkit-picker__panel'); + var empty = make('p', 'wptoolkit-picker__empty', { hidden: 'hidden' }); + empty.textContent = labels.none; + + options.forEach(function (option) { panel.appendChild(option.row); }); + panel.appendChild(empty); + control.appendChild(chips); + control.appendChild(search); + root.appendChild(control); + root.appendChild(panel); + + function open(show) { root.classList.toggle('is-open', show); } + + function visibleOptions() { + return options.filter(function (option) { return !option.row.hidden; }); + } + + function filter() { + var query = search.value.trim().toLowerCase(); + options.forEach(function (option) { + option.row.hidden = query !== '' && option.text.toLowerCase().indexOf(query) === -1; + }); + empty.hidden = visibleOptions().length > 0; + } + + function renderChips() { + chips.textContent = ''; + options.forEach(function (option) { + if (!option.box.checked) { return; } + var chip = make('li', 'wptoolkit-picker__chip'); + var text = make('span'); + text.textContent = option.text; + var remove = make('button', 'wptoolkit-picker__remove', { + type: 'button', 'aria-label': labels.remove.replace('%s', option.text) + }); + remove.textContent = '×'; + remove.addEventListener('click', function () { + toggle(option.box, false); + search.focus(); + }); + chip.appendChild(text); + chip.appendChild(remove); + chips.appendChild(chip); + }); + } + + boxes.forEach(function (box) { box.addEventListener('change', renderChips); }); + search.addEventListener('input', function () { filter(); open(true); }); + search.addEventListener('focus', function () { open(true); }); + control.addEventListener('click', function () { search.focus(); open(true); }); + + search.addEventListener('keydown', function (event) { + if (event.key === 'Enter') { + // Enter picks the first match instead of submitting the form. + event.preventDefault(); + var first = visibleOptions()[0]; + if (first) { + toggle(first.box, !first.box.checked); + search.value = ''; + filter(); + } + } else if (event.key === 'Backspace' && search.value === '') { + var selected = options.filter(function (option) { return option.box.checked; }); + var last = selected[selected.length - 1]; + if (last) { toggle(last.box, false); } + } else if (event.key === 'Escape') { + open(false); + } + }); + root.addEventListener('keydown', function (event) { + if (event.key === 'Escape') { open(false); search.focus(); } + }); + root.addEventListener('focusout', function (event) { + if (!event.relatedTarget || !root.contains(event.relatedTarget)) { open(false); } + }); + document.addEventListener('mousedown', function (event) { + if (!root.contains(event.target)) { open(false); } + }); + if (root.form) { + root.form.addEventListener('reset', function () { window.setTimeout(renderChips, 0); }); + } + + renderChips(); + } + + function init() { + [].slice.call(document.querySelectorAll('[data-wptoolkit-picker="tokens"]')).forEach(enhance); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +}()); diff --git a/src/Data/Field/Field.php b/src/Data/Field/Field.php index 7238a48..47e4c03 100644 --- a/src/Data/Field/Field.php +++ b/src/Data/Field/Field.php @@ -78,6 +78,20 @@ public function multiple(bool $multiple = true): self return $this->with('multiple', $multiple); } + /** + * How a multiple-choice field looks: 'checkboxes' (a plain list), 'tokens' (chips with search; the + * checkboxes are enhanced with JavaScript) or 'native' (the browser's `', Html::attributes([ 'id' => $id, @@ -88,4 +95,73 @@ public function render(Field $field, string $name, string $id, mixed $value): st $options ); } + + /** + * Sent first so that ticking nothing still submits the field. Without it the key is missing, the + * save treats the field as "not on this form", and a selection could never be cleared. + */ + private static function emptyMarker(string $name): string + { + return sprintf('', esc_attr($name)); + } + + private function displayOf(Field $field): string + { + $display = $field->setting('display'); + if (is_string($display)) { + return $display; + } + + return count($field->choices()) <= self::MAX_PLAIN_CHECKBOXES ? 'checkboxes' : 'tokens'; + } + + /** + * A multiple choice as real checkboxes. With 'tokens' a script (ChoicePickerAssets) turns the list + * into chips with search; the checkboxes remain the source of truth either way. + * + * @param list $selected + */ + private function renderCheckboxes(Field $field, string $name, string $id, array $selected): string + { + $mode = $this->displayOf($field); + $attributes = $field->htmlAttributes(); + $class = trim('wptoolkit-picker ' . (isset($attributes['class']) ? (string) $attributes['class'] : '')); + unset($attributes['class']); + + $boxes = ''; + foreach ($field->choices() as $option => $label) { + $boxId = $id . '-' . sanitize_key((string) $option); + $boxes .= sprintf( + '', + esc_attr($boxId), + Html::attributes([ + 'type' => 'checkbox', + 'id' => $boxId, + 'name' => $name . '[]', + 'value' => (string) $option, + 'checked' => in_array((string) $option, $selected, true), + ]), + esc_html($label) + ); + } + + $labels = []; + if ($mode === 'tokens') { + $this->assets?->queue(); + $labels = [ + 'data-label-search' => __('Search…', 'wptoolkit'), + /* translators: %s: name of a selected item */ + 'data-label-remove' => __('Remove %s', 'wptoolkit'), + 'data-label-none' => __('No matches', 'wptoolkit'), + ]; + } + + return sprintf( + '%s%s%s', + Html::attributes(['id' => $id, 'class' => $class, 'data-wptoolkit-picker' => $mode] + $labels + $attributes), + esc_html($field->labelText()), + self::emptyMarker($name), + $boxes + ); + } } diff --git a/tests/E2E/todo-example.spec.ts b/tests/E2E/todo-example.spec.ts index 54c09e0..d0ddd67 100644 --- a/tests/E2E/todo-example.spec.ts +++ b/tests/E2E/todo-example.spec.ts @@ -111,6 +111,59 @@ test.describe.serial('the todo example (Phase 5 DoD)', () => { expect(problems(takeDebugLog())).toEqual([]); }); + test('a long multiple-choice field is a chip picker: search, pick, remove, save, clear', async ({ page }) => { + const pageErrors: string[] = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + const key = 'todo_details_wptk_todo_labels'; + + await page.goto(NEW_TODO); + await page.fill('#title', 'Picker todo'); + const picker = page.locator('[data-wptoolkit-picker="tokens"]'); + await expect(picker).toHaveAttribute('data-wptoolkit-ready', '1'); + const search = picker.getByRole('textbox', { name: 'Search…' }); + const panel = picker.locator('.wptoolkit-picker__panel'); + const chips = picker.locator('.wptoolkit-picker__chip span'); + + await expect(panel, 'closed until the search box is used').toBeHidden(); + await search.click(); + await expect(panel).toBeVisible(); + + await search.fill('fin'); + await expect(panel.locator('label:visible')).toHaveCount(1); + await page.keyboard.press('Enter'); // picks the first match instead of submitting the form + await search.fill('tra'); + await page.keyboard.press('Enter'); + await search.fill('zzz'); + await expect(picker.locator('.wptoolkit-picker__empty')).toBeVisible(); + await search.fill(''); + await expect(chips).toHaveText(['Finance', 'Travel']); + + await picker.getByRole('button', { name: 'Remove Travel' }).click(); + await expect(chips).toHaveText(['Finance']); + await page.keyboard.press('Escape'); + await expect(panel).toBeHidden(); + + await page.click('#publish'); + await expect(page.locator('#message')).toBeVisible(); + const postId = new URL(page.url()).searchParams.get('post') ?? ''; + expect(postId).not.toBe(''); + + // Saved exactly as before: one serialized row holding the chosen values. + expect(wp('post', 'meta', 'get', postId, key, '--format=json').output.trim()).toBe('["finance"]'); + expect(wp('post', 'meta', 'list', postId, `--keys=${key}`, '--format=count').output.trim()).toBe('1'); + await expect(chips).toHaveText(['Finance']); + + // Removing the last choice and saving must clear it (an empty selection is still sent). + await picker.getByRole('button', { name: 'Remove Finance' }).click(); + await page.click('#publish'); + await expect(page.locator('#message')).toBeVisible(); + expect(wp('post', 'meta', 'list', postId, `--keys=${key}`, '--format=count').output.trim()).toBe('0'); + await expect(picker.locator('.wptoolkit-picker__chip')).toHaveCount(0); + + expect(pageErrors).toEqual([]); + expect(problems(takeDebugLog())).toEqual([]); + }); + test('Arabic: screens mirror right to left and use the example’s translations', async ({ page }) => { expect(wp('language', 'core', 'install', 'ar').code).toBe(0); expect(wp('site', 'switch-language', 'ar').code).toBe(0); diff --git a/tests/Unit/Build/BuildRobustnessTest.php b/tests/Unit/Build/BuildRobustnessTest.php new file mode 100644 index 0000000..f7a03fd --- /dev/null +++ b/tests/Unit/Build/BuildRobustnessTest.php @@ -0,0 +1,436 @@ +root = sys_get_temp_dir() . '/wptoolkit-robust-' . bin2hex(random_bytes(4)); + $this->project = $this->root . '/my-plugin'; + mkdir($this->project . '/src', 0777, true); + file_put_contents($this->project . '/my-plugin.php', "project . '/src/Plugin.php', 'root, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($items as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($this->root); + } + + // --- One build per project ----------------------------------------------------------------- + + public function test_a_second_build_of_the_same_project_is_refused_while_one_is_running(): void + { + $holder = $this->holdLockInAnotherProcess(); + + try { + $this->build()->zip('dist/out.zip'); + self::fail('Expected the second build to be refused.'); + } catch (BuildException $e) { + self::assertStringContainsString('Another build of this project is running', $e->getMessage()); + self::assertMatchesRegularExpression('/process \d+/', $e->getMessage(), 'it says who holds the lock'); + } finally { + $this->releaseHolder($holder); + } + self::assertFileDoesNotExist($this->project . '/dist/out.zip'); + } + + public function test_wait_for_lock_starts_as_soon_as_the_other_build_finishes(): void + { + $holder = $this->holdLockInAnotherProcess(); + $release = $this->root . '/release-lock'; + $checks = 0; + // Checked while the build waits for the lock: the first time, the other build lets go. That + // makes "it really waited, then went ahead" independent of how fast this machine is. + $whileWaiting = Interruption::when(static function () use (&$checks, $release): bool { + $checks++; + touch($release); + + return false; + }); + + try { + $this->build()->waitForLock(30)->withInterruption($whileWaiting)->zip('dist/out.zip'); + } finally { + $this->releaseHolder($holder); + } + + self::assertGreaterThanOrEqual(1, $checks, 'it waited for the other build instead of failing'); + self::assertFileExists($this->project . '/dist/out.zip'); + } + + public function test_the_lock_is_released_after_a_build_whether_it_succeeds_or_fails(): void + { + $this->build()->zip('dist/out.zip'); + try { + $this->build()->run($this->script('fails', 'exit(2);'))->zip('dist/out.zip'); + } catch (BuildException) { + } + + $this->build()->zip('dist/out.zip'); // would throw if either build had kept the lock + self::assertFileExists($this->project . '/dist/out.zip'); + } + + public function test_the_lock_names_the_project_however_its_path_is_spelled(): void + { + self::assertSame( + BuildLock::path(Project::plugin($this->project)), + BuildLock::path(Project::plugin($this->project . '/src/..')), + ); + } + + public function test_different_projects_do_not_block_each_other(): void + { + $other = $this->root . '/other-plugin'; + mkdir($other); + file_put_contents($other . '/other-plugin.php', "holdLockInAnotherProcess(); + + try { + Build::plugin($other)->quiet()->zip('dist/out.zip'); + } finally { + $this->releaseHolder($holder); + } + + self::assertFileExists($other . '/dist/out.zip'); + } + + // --- Output is the old file or the new one, never half of one -------------------------------- + + public function test_a_zip_build_leaves_only_the_zip_and_its_checksum(): void + { + $this->build()->zip('dist/out.zip'); + $this->build()->zip('dist/out.zip'); + + $files = array_values(array_diff((array) scandir($this->project . '/dist'), ['.', '..'])); + self::assertSame(['out.zip', 'out.zip.sha256'], $files, 'no temporary files are left next to the output'); + self::assertStringStartsWith(hash_file('sha256', $this->project . '/dist/out.zip') . ' out.zip', (string) file_get_contents($this->project . '/dist/out.zip.sha256')); + } + + public function test_the_previous_zip_survives_a_build_that_is_stopped_while_packaging(): void + { + $this->build()->zip('dist/out.zip'); + $before = hash_file('sha256', $this->project . '/dist/out.zip'); + file_put_contents($this->project . '/src/New.php', 'build()->withInterruption($this->stopAtTheLastCheckOf(fn (Build $build) => $build->zip('dist/probe.zip')))->zip('dist/out.zip'); + self::fail('Expected the build to stop.'); + } catch (BuildException) { + } + + self::assertSame($before, hash_file('sha256', $this->project . '/dist/out.zip')); + self::assertSame( + ['out.zip', 'out.zip.sha256', 'probe.zip', 'probe.zip.sha256'], + array_values(array_diff((array) scandir($this->project . '/dist'), ['.', '..'])), + 'nothing half-written is left next to it' + ); + } + + public function test_a_directory_build_leaves_no_temporary_folders_beside_it(): void + { + $this->build()->toDirectory($this->root . '/out'); + $this->build()->toDirectory($this->root . '/out'); + + $siblings = array_values(array_filter( + array_diff((array) scandir($this->root), ['.', '..']), + static fn (string $name): bool => str_starts_with($name, 'out') + )); + self::assertSame(['out'], $siblings); + self::assertFileExists($this->root . '/out/my-plugin.php'); + } + + public function test_the_previous_directory_survives_a_build_that_is_stopped_while_writing(): void + { + $this->build()->toDirectory($this->root . '/out'); + file_put_contents($this->project . '/src/New.php', 'stopAtTheLastCheckOf(fn (Build $build) => $build->toDirectory($this->root . '/probe')); + $this->build()->withInterruption($stop)->toDirectory($this->root . '/out'); + self::fail('Expected the build to stop.'); + } catch (BuildException) { + } + + self::assertFileExists($this->root . '/out/src/Plugin.php', 'the earlier build is still there, whole'); + self::assertFileDoesNotExist($this->root . '/out/src/New.php'); + $siblings = array_values(array_filter(array_diff((array) scandir($this->root), ['.', '..']), static fn (string $n): bool => str_starts_with($n, 'out'))); + self::assertSame(['out'], $siblings, 'the half-written copy was removed'); + } + + // --- Interruption ---------------------------------------------------------------------------- + + public function test_a_command_is_polled_while_it_runs_and_can_be_stopped(): void + { + $marker = $this->root . '/finished.txt'; + $slow = $this->script('slow', 'echo "started\n"; sleep(8); file_put_contents(' . var_export($marker, true) . ', "done");'); + $ticks = 0; + $started = microtime(true); + + try { + (new ProcessCommandRunner())->watch($slow, $this->root, static function (string $line): void { + }, static function () use (&$ticks, $started): void { + $ticks++; + if (microtime(true) - $started > 1.0) { + throw new \RuntimeException('stop'); + } + }); + self::fail('The tick callback should have stopped the command.'); + } catch (\RuntimeException $e) { + self::assertSame('stop', $e->getMessage()); + } + + self::assertGreaterThan(3, $ticks, 'polled several times a second'); + self::assertLessThan(6, microtime(true) - $started, 'returned without waiting for the command to end'); + sleep(1); + self::assertFileDoesNotExist($marker, 'the command was terminated, not left running'); + } + + public function test_watch_returns_the_same_result_as_run(): void + { + $command = $this->script('out', 'echo "one\n"; fwrite(STDERR, "two\n"); exit(3);'); + $lines = []; + + $result = (new ProcessCommandRunner())->watch($command, $this->root, static function (string $line) use (&$lines): void { + $lines[] = $line; + }, static function (): void { + }); + + self::assertSame(3, $result['exitCode']); + self::assertSame(['one', 'two'], $lines); + self::assertSame("one\ntwo\n", str_replace("\r\n", "\n", $result['output'])); + } + + public function test_an_interrupted_build_cleans_up_and_releases_the_lock(): void + { + $interruption = new Interruption(); + $slow = $this->script('slow', 'sleep(8);'); + $stagingBefore = $this->stagingDirectories(); + $started = microtime(true); + $trigger = new class ($interruption) implements BuildStep { + public function __construct(private readonly Interruption $interruption) + { + } + + public function name(): string + { + return 'ctrl-c'; + } + + public function phase(): Phase + { + return Phase::Prepare; + } + + public function run(BuildContext $context): void + { + $this->interruption->trigger(); // as a signal handler would, just before a long command + } + }; + + try { + $this->build()->withInterruption($interruption)->step($trigger)->run($slow)->zip('dist/out.zip'); + self::fail('Expected the build to stop.'); + } catch (BuildException $e) { + self::assertStringContainsString('interrupted', strtolower($e->getMessage())); + } + + self::assertLessThan(6, microtime(true) - $started, 'the long command was not waited for'); + self::assertSame($stagingBefore, $this->stagingDirectories(), 'no staging copy is left behind'); + self::assertFileDoesNotExist($this->project . '/dist/out.zip'); + $this->build()->zip('dist/out.zip'); // the lock was released + } + + /** + * The real thing, where the platform can deliver it: Ctrl+C (SIGINT) sent to a running build + * process. The child must stop quickly, take its command down with it, and leave nothing behind. + */ + public function test_a_real_sigint_stops_the_build_and_the_command_it_started(): void + { + if (PHP_OS_FAMILY === 'Windows' || !extension_loaded('pcntl') || !defined('SIGINT')) { + self::markTestSkipped('Delivering SIGINT to a child process needs a POSIX system with the pcntl extension.'); + } + + $started = $this->root . '/started'; + $heartbeat = $this->root . '/heartbeat'; + $command = $this->script('beat', sprintf( + 'touch(%1$s); for ($i = 0; $i < 80; $i++) { usleep(250000); touch(%2$s); }', + var_export($started, true), + var_export($heartbeat, true) + )); + $runner = $this->root . '/run-build.php'; + file_put_contents($runner, sprintf( + "quiet()->run(%s)->zip('dist/out.zip');\n", + var_export(dirname(__DIR__, 3) . '/vendor/autoload.php', true), + var_export($this->project, true), + var_export($command, true) + )); + $stagingBefore = $this->stagingDirectories(); + + // `exec` so the process we signal is PHP itself, not a shell in front of it. + $process = proc_open('exec ' . escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($runner), [], $pipes, $this->root); + self::assertIsResource($process); + for ($i = 0; $i < 100 && !is_file($heartbeat); $i++) { + usleep(100000); + } + self::assertFileExists($heartbeat, 'the build got as far as running its command'); + + $signalled = microtime(true); + proc_terminate($process, SIGINT); + for ($i = 0; $i < 100 && proc_get_status($process)['running']; $i++) { + usleep(100000); + } + $stopped = !proc_get_status($process)['running']; + if (!$stopped) { + proc_terminate($process, SIGKILL); + } + proc_close($process); + + self::assertTrue($stopped, 'the build process ended after SIGINT'); + self::assertLessThan(8, microtime(true) - $signalled, 'and promptly, not after the 20 second command'); + + $last = (int) filemtime($heartbeat); + sleep(2); + clearstatcache(); + self::assertSame($last, (int) filemtime($heartbeat), 'the command it started is no longer running'); + self::assertSame($stagingBefore, $this->stagingDirectories(), 'no staging copy is left behind'); + self::assertFileDoesNotExist($this->project . '/dist/out.zip'); + $this->build()->zip('dist/out.zip'); // and the lock was released + } + + public function test_staging_left_by_builds_that_were_killed_is_swept_but_recent_ones_are_kept(): void + { + $old = sys_get_temp_dir() . '/wptoolkit-build-' . bin2hex(random_bytes(6)); + $recent = sys_get_temp_dir() . '/wptoolkit-build-' . bin2hex(random_bytes(6)); + foreach ([$old, $recent] as $directory) { + mkdir($directory . '/some-plugin', 0777, true); + file_put_contents($directory . '/some-plugin/file.php', 'build()->zip('dist/out.zip'); + + self::assertDirectoryDoesNotExist($old); + self::assertDirectoryExists($recent, 'it may belong to a build that is running now'); + } finally { + @unlink($recent . '/some-plugin/file.php'); + @rmdir($recent . '/some-plugin'); + @rmdir($recent); + } + } + + private function build(): Build + { + return Build::plugin($this->project)->quiet(); + } + + /** + * An interruption that fires at the very last point a build checks for one — which is while the + * output is being written, file by file. A first run of the same build counts the checks. + * + * @param \Closure(Build): mixed $sameBuild + */ + private function stopAtTheLastCheckOf(\Closure $sameBuild): Interruption + { + $total = 0; + $sameBuild($this->build()->withInterruption(Interruption::when(static function () use (&$total): bool { + $total++; + return false; + }))); + self::assertGreaterThan(2, $total, 'the build checks for interruption while it writes'); + + $seen = 0; + return Interruption::when(static function () use (&$seen, $total): bool { + return ++$seen >= $total; + }); + } + + /** + * A process that takes this project's build lock and keeps it until releaseHolder() — however long + * the test takes — so no test depends on a timer. It runs from the system temp directory, not + * from the test's folder, so that folder can be deleted while it is still shutting down. + * + * @return resource + */ + private function holdLockInAnotherProcess() + { + $release = $this->root . '/release-lock'; + $script = $this->root . '/hold.php'; + file_put_contents($script, strtr(<<<'PHP' + release(); + PHP, [ + '{AUTOLOAD}' => var_export(dirname(__DIR__, 3) . '/vendor/autoload.php', true), + '{PROJECT}' => var_export($this->project, true), + '{RELEASE}' => var_export($release, true), + ])); + + $process = proc_open(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($script), [1 => ['pipe', 'w']], $pipes, sys_get_temp_dir()); + self::assertIsResource($process); + self::assertSame("held", trim((string) fgets($pipes[1])), 'the other process took the lock'); + + return $process; + } + + /** + * @param resource $holder + */ + private function releaseHolder($holder): void + { + touch($this->root . '/release-lock'); + proc_close($holder); + } + + /** + * @return list + */ + private function stagingDirectories(): array + { + $found = glob(sys_get_temp_dir() . '/wptoolkit-build-*', GLOB_ONLYDIR) ?: []; + sort($found); + + return array_values(array_filter($found, static fn (string $d): bool => preg_match('/wptoolkit-build-[0-9a-f]{12}$/', $d) === 1)); + } + + private function script(string $name, string $body): string + { + $file = $this->root . '/' . $name . '.php'; + file_put_contents($file, " */ + private array $lines = []; + + private float $now = 100.0; + + public function test_plain_output_is_one_line_per_event_with_step_durations(): void + { + $progress = $this->plain(); + + $progress->stepStarted('run'); + $progress->line(' $ npm run build'); + $this->now += 12.44; + $progress->stepFinished(); + $progress->stepStarted('verify'); + $this->now += 0.02; + $progress->stepFinished(); + + self::assertSame(['→ run', ' $ npm run build', ' ✓ 12.4s', '→ verify', ' ✓ 0.0s'], $this->lines); + } + + public function test_long_steps_are_shown_in_minutes(): void + { + $progress = $this->plain(); + $progress->stepStarted('run'); + $this->now += 125.3; + $progress->stepFinished(); + + self::assertSame(' ✓ 2m 05s', $this->lines[1]); + } + + public function test_plain_output_contains_no_terminal_control_codes_even_when_ticked(): void + { + $progress = $this->plain(); + $progress->stepStarted('run'); + $progress->activity('npm run build'); + $progress->tick(); + $progress->tick(); + $progress->activity(null); + $progress->stepFinished(); + + self::assertStringNotContainsString("\r", implode("\n", $this->lines)); + self::assertStringNotContainsString("\033", implode("\n", $this->lines)); + self::assertCount(2, $this->lines, 'ticks print nothing without a terminal'); + } + + public function test_a_failed_step_is_marked_and_keeps_its_duration(): void + { + $progress = $this->plain(); + $progress->stepStarted('run'); + $this->now += 3; + $progress->stepFailed(); + + self::assertSame(['→ run', ' ✗ 3.0s'], $this->lines); + } + + public function test_quiet_prints_only_warnings(): void + { + $progress = new Progress(fn (string $line) => $this->lines[] = $line, quiet: true, clock: fn (): float => $this->now); + + $progress->stepStarted('run'); + $progress->line('progress'); + $progress->warn('read me'); + $progress->stepFinished(); + + self::assertSame([' ! read me'], $this->lines); + } + + public function test_an_interactive_terminal_gets_a_spinner_that_leaves_the_same_lines_behind(): void + { + $terminal = fopen('php://memory', 'w+'); + self::assertIsResource($terminal); + $progress = new Progress(static function (): void { + }, terminal: $terminal, clock: fn (): float => $this->now); + + $progress->stepStarted('run'); + $progress->line(' $ npm run build'); + $progress->activity('npm run build'); + $progress->tick(); + $this->now += 3.2; + $progress->tick(); + $progress->activity(null); + $this->now += 1; + $progress->stepFinished(); + + rewind($terminal); + $raw = (string) stream_get_contents($terminal); + self::assertStringContainsString("\r", $raw, 'the spinner line is redrawn in place'); + self::assertMatchesRegularExpression('/npm run build\s+3s/', $raw, 'it shows what is running and for how long'); + + self::assertSame( + ['→ run', ' $ npm run build', ' ✓ 4.2s'], + $this->whatStaysOnScreen($raw), + 'once the step ends, the screen shows exactly what a log would' + ); + } + + public function test_the_spinner_advances_and_falls_back_to_ascii_frames(): void + { + $terminal = fopen('php://memory', 'w+'); + self::assertIsResource($terminal); + $progress = new Progress(static function (): void { + }, terminal: $terminal, unicode: false, clock: fn (): float => $this->now); + + $progress->stepStarted('run'); + $progress->activity('build'); + foreach (range(1, 4) as $ignored) { + $this->now += 0.2; + $progress->tick(); + } + + rewind($terminal); + $raw = (string) stream_get_contents($terminal); + self::assertMatchesRegularExpression('#[|/\\\\-] build#', $raw); + self::assertGreaterThan(1, count(array_unique(array_filter(['|', '/', '-', '\\'], static fn (string $f): bool => str_contains($raw, $f . ' build'))))); + self::assertDoesNotMatchRegularExpression('/[^\x00-\x7F]/', str_replace(['→', '✓'], '', $raw), 'only the two status marks are non-ASCII'); + } + + public function test_a_step_whose_output_is_streamed_has_no_spinner(): void + { + $terminal = fopen('php://memory', 'w+'); + self::assertIsResource($terminal); + $progress = new Progress(static function (): void { + }, terminal: $terminal, clock: fn (): float => $this->now); + + $progress->stepStarted('run'); + $progress->activity(null); // streamed: the command's own lines are the progress + $progress->line(' | compiling'); + $progress->tick(); + $progress->stepFinished(); + + rewind($terminal); + self::assertStringNotContainsString("\r", (string) stream_get_contents($terminal)); + } + + public function test_a_build_with_its_own_output_handler_never_gets_a_spinner(): void + { + $project = sys_get_temp_dir() . '/wptoolkit-progress-' . bin2hex(random_bytes(4)) . '/my-plugin'; + mkdir($project, 0777, true); + file_put_contents($project . '/my-plugin.php', "withOutput(function (string $line): void { + $this->lines[] = $line; + })->zip('dist/out.zip'); + } finally { + foreach (['/dist/out.zip', '/dist/out.zip.sha256', '/my-plugin.php'] as $file) { + @unlink($project . $file); + } + @rmdir($project . '/dist'); + rmdir($project); + rmdir(dirname($project)); + } + + // The summary is one multi-line string joined with PHP_EOL, so "\r\n" is fine on Windows; a + // carriage return on its own would be a spinner redraw. + $output = str_replace("\r\n", "\n", implode("\n", $this->lines)); + self::assertStringNotContainsString("\r", $output); + self::assertStringNotContainsString("\033", $output); + self::assertStringContainsString("→ verify\n ✓ ", $output); + } + + private function plain(): Progress + { + return new Progress(function (string $line): void { + $this->lines[] = $line; + }, clock: fn (): float => $this->now); + } + + /** + * What a terminal shows after the raw bytes are played back: `\r` returns to the start of the + * line and `ESC[2K` clears it. + * + * @return list + */ + private function whatStaysOnScreen(string $raw): array + { + $screen = []; + foreach (explode("\n", $raw) as $line) { + $parts = explode("\r", $line); + $last = (string) end($parts); + $screen[] = str_replace("\033[2K", '', $last); + } + + return array_values(array_filter($screen, static fn (string $line): bool => $line !== '')); + } +} diff --git a/tests/Unit/Data/FieldTest.php b/tests/Unit/Data/FieldTest.php index 888382b..98db9f1 100644 --- a/tests/Unit/Data/FieldTest.php +++ b/tests/Unit/Data/FieldTest.php @@ -111,10 +111,15 @@ public function test_rendered_values_and_options_are_escaped(): void public function test_multiple_select_submits_an_array(): void { - $html = $this->types->get('select')->render($this->f->select('tags', ['a' => 'A'])->multiple(), 'tags', 'tags', ['a']); + $field = $this->f->select('tags', ['a' => 'A'])->multiple(); - self::assertStringContainsString('name="tags[]"', $html); - self::assertStringContainsString(' multiple', $html); + $default = $this->types->get('select')->render($field, 'tags', 'tags', ['a']); + self::assertStringContainsString('name="tags[]"', $default); + self::assertStringContainsString('type="checkbox"', $default, 'multiple choices are checkboxes by default'); + + $native = $this->types->get('select')->render($field->display('native'), 'tags', 'tags', ['a']); + self::assertStringContainsString('name="tags[]"', $native); + self::assertStringContainsString(' multiple', $native); } public function test_custom_types_plug_in_without_changing_the_library(): void diff --git a/tests/Unit/Data/MetaBoxTest.php b/tests/Unit/Data/MetaBoxTest.php index 39533ef..ef0dbbd 100644 --- a/tests/Unit/Data/MetaBoxTest.php +++ b/tests/Unit/Data/MetaBoxTest.php @@ -168,6 +168,29 @@ public function test_blank_sensitive_field_keeps_the_stored_secret(): void self::assertSame(['sk_live'], $this->wp->meta[5]['d_book_api_key']); } + public function test_deselecting_every_choice_clears_a_multiple_field(): void + { + // The form sends the empty marker even with nothing ticked; without it, the key is absent, + // the field is "not on this form", and the old selection could never be removed. + $this->wp->meta[5] = ['d_book_tags' => [['a', 'b']]]; + $box = $this->box('d', 'book', [$this->f->select('tags', ['a' => 'A', 'b' => 'B'])->multiple()]); + $_POST = [$box->nonceName() => 'n', 'd_book_tags' => ['']]; + + $box->handleSave(5, $this->post(5, 'book')); + + self::assertArrayNotHasKey('d_book_tags', $this->wp->meta[5]); + } + + public function test_the_empty_marker_does_not_disturb_a_real_selection(): void + { + $box = $this->box('d', 'book', [$this->f->select('tags', ['a' => 'A', 'b' => 'B'])->multiple()]); + $_POST = [$box->nonceName() => 'n', 'd_book_tags' => ['', 'b']]; + + $box->handleSave(5, $this->post(5, 'book')); + + self::assertSame([['b']], $this->wp->meta[5]['d_book_tags']); + } + public function test_fields_missing_from_the_form_are_left_alone(): void { $this->wp->meta[5] = ['d_book_title' => ['Kept']]; diff --git a/tests/Unit/Data/MultipleChoiceTest.php b/tests/Unit/Data/MultipleChoiceTest.php new file mode 100644 index 0000000..6819073 --- /dev/null +++ b/tests/Unit/Data/MultipleChoiceTest.php @@ -0,0 +1,171 @@ + selects one item per click and can't be cleared + * (nothing selected submits nothing), so they render as checkbox lists, enhanced into a searchable + * chip picker. The checkboxes stay the source of truth. + */ +final class MultipleChoiceTest extends TestCase +{ + private FieldFactory $f; + + private FieldTypes $types; + + protected function setUp(): void + { + parent::setUp(); + $this->f = new FieldFactory(); + $this->types = new FieldTypes(); + } + + /** + * @param array $options + */ + private function render(array $options, mixed $value = [], ?string $display = null, string $name = 'tags'): string + { + $field = $this->f->select($name, $options)->multiple(); + if ($display !== null) { + $field = $field->display($display); + } + + return $this->types->get('select')->render($field->label('Tags'), $name, $name, $value); + } + + public function test_a_short_list_is_a_checkbox_group_that_submits_an_array(): void + { + $html = $this->render(['a' => 'Alpha', 'b' => 'Beta'], ['b']); + + self::assertStringContainsString(']* checked/', $html); + self::assertDoesNotMatchRegularExpression('/value="a"[^>]* checked/', $html); + self::assertStringNotContainsString('render($options)); + } + + public function test_the_mode_can_be_chosen_explicitly(): void + { + $options = ['a' => 'A', 'b' => 'B']; + + self::assertStringContainsString('data-wptoolkit-picker="tokens"', $this->render($options, [], 'tokens')); + self::assertStringContainsString('render($options, [], 'native')); + self::assertStringContainsString(' multiple', $this->render($options, [], 'native')); + } + + public function test_an_unknown_mode_is_refused_when_the_field_is_defined(): void + { + $this->expectException(InvalidConfigException::class); + $this->f->select('tags', ['a' => 'A'])->multiple()->display('fancy'); + } + + public function test_every_display_sends_an_empty_marker_so_deselecting_everything_is_saved(): void + { + foreach ([null, 'checkboxes', 'tokens', 'native'] as $display) { + $html = $this->render(['a' => 'A', 'b' => 'B'], ['a'], $display); + + self::assertStringContainsString('', $html, (string) $display); + self::assertLessThan( + strpos($html, 'name="tags[]"', strpos($html, 'type="hidden"') + 10) ?: PHP_INT_MAX, + strpos($html, 'type="hidden"'), + 'the marker comes first (' . $display . ')' + ); + } + } + + public function test_options_values_and_labels_are_escaped_and_ids_are_unique(): void + { + $html = $this->render(['x">' => '', 'plain' => 'Plain'], [], 'checkboxes'); + + self::assertStringNotContainsString('render(['a' => 'Alpha'], [], 'checkboxes'); + + self::assertMatchesRegularExpression('/]*>.*]*type="checkbox".*Alpha/s', $html); + self::assertStringContainsString('Tags', $html); + } + + public function test_a_single_select_and_radio_are_unchanged(): void + { + $single = $this->types->get('select')->render($this->f->select('size', ['s' => 'S', 'm' => 'M']), 'size', 'size', 'm'); + + self::assertStringStartsWith('types->get('radio')->render($this->f->radio('r', ['a' => 'A']), 'r', 'r', 'a')); + } + + public function test_the_picker_script_and_style_are_printed_once_in_the_footer_however_many_fields(): void + { + $printers = []; + Actions\expectAdded('admin_print_footer_scripts')->zeroOrMoreTimes()->whenHappen(static function ($callback) use (&$printers): void { + $printers[] = $callback; + }); + Actions\expectAdded('wp_print_footer_scripts')->zeroOrMoreTimes()->whenHappen(static function ($callback) use (&$printers): void { + $printers[] = $callback; + }); + $options = array_combine(range('a', 'h'), range('A', 'H')); + + $this->render($options, [], null, 'one'); + $this->render($options, [], null, 'two'); + $this->render(['a' => 'A'], [], 'checkboxes', 'three'); + + self::assertNotEmpty($printers, 'assets are queued for the footer, not printed inline'); + ob_start(); + foreach ($printers as $printer) { + $printer(); + } + $output = (string) ob_get_clean(); + + self::assertSame(1, substr_count($output, ''), 'the script holds no closing tag that would end it early'); + } + + public function test_checkbox_only_lists_need_no_script(): void + { + $printers = []; + Actions\expectAdded('admin_print_footer_scripts')->zeroOrMoreTimes()->whenHappen(static function ($callback) use (&$printers): void { + $printers[] = $callback; + }); + + $this->render(['a' => 'A', 'b' => 'B'], [], 'checkboxes'); + + self::assertSame([], $printers); + } + + public function test_empty_entries_are_ignored_when_validating_a_multiple_field(): void + { + $validator = new FieldValidator($this->types); + $field = $this->f->select('tags', ['a' => 'A', 'b' => 'B'])->multiple(); + + self::assertSame([], $validator->validate($field, ['', 'a']), 'the empty marker is not a choice'); + self::assertSame([], $validator->validate($field, ['']), 'nothing chosen is fine when the field is optional'); + self::assertNotSame([], $validator->validate($field->required(), ['']), 'and counts as nothing when it is required'); + self::assertNotSame([], $validator->validate($field, ['', 'zzz']), 'a real bad value is still caught'); + } +}