Browse Source

Merge branch '4.next' into 5.x

ADmad 4 years ago
parent
commit
bd5bd613a4

+ 4 - 1
composer.json

@@ -67,7 +67,10 @@
     },
     "config": {
         "process-timeout": 900,
-        "sort-packages": true
+        "sort-packages": true,
+        "allow-plugins": {
+            "dealerdirect/phpcodesniffer-composer-installer": true
+        }
     },
     "autoload": {
         "psr-4": {

+ 8 - 5
src/Core/App.php

@@ -61,13 +61,16 @@ class App
         }
 
         [$plugin, $name] = pluginSplit($class);
-        $base = $plugin ?: Configure::read('App.namespace');
-        $base = str_replace('/', '\\', rtrim($base, '\\'));
         $fullname = '\\' . str_replace('/', '\\', $type . '\\' . $name) . $suffix;
 
-        if (static::_classExistsInBase($fullname, $base)) {
-            /** @var class-string */
-            return $base . $fullname;
+        $base = $plugin ?: Configure::read('App.namespace');
+        if ($base !== null) {
+            $base = str_replace('/', '\\', rtrim($base, '\\'));
+
+            if (static::_classExistsInBase($fullname, $base)) {
+                /** @var class-string */
+                return $base . $fullname;
+            }
         }
 
         if ($plugin || !static::_classExistsInBase($fullname, 'Cake')) {

+ 2 - 2
src/Datasource/composer.json

@@ -26,8 +26,8 @@
     "require": {
         "php": ">=8.0",
         "cakephp/core": "^5.0",
-        "psr/log": "^1.1",
-        "psr/simple-cache": "^1.0"
+        "psr/log": "^3.0",
+        "psr/simple-cache": "^2.0 || ^3.0"
     },
     "suggest": {
         "cakephp/utility": "If you decide to use EntityTrait.",

+ 7 - 8
src/Error/Debugger.php

@@ -31,8 +31,8 @@ use Cake\Error\Debug\ReferenceNode;
 use Cake\Error\Debug\ScalarNode;
 use Cake\Error\Debug\SpecialNode;
 use Cake\Error\Debug\TextFormatter;
-use Cake\Error\Renderer\HtmlRenderer;
-use Cake\Error\Renderer\TextRenderer;
+use Cake\Error\Renderer\HtmlErrorRenderer;
+use Cake\Error\Renderer\TextErrorRenderer;
 use Cake\Log\Log;
 use Cake\Utility\Hash;
 use Cake\Utility\Security;
@@ -123,11 +123,9 @@ class Debugger
      * @var array<string, class-string>
      */
     protected array $renderers = [
-        // Backwards compatible alias for text that will be deprecated.
-        'txt' => TextRenderer::class,
-        'text' => TextRenderer::class,
-        // The html alias currently uses no JS and will be deprecated.
-        'js' => HtmlRenderer::class,
+        'txt' => TextErrorRenderer::class,
+        // The html alias ycurrently uses no JS and will be deprecated.
+        'js' => HtmlErrorRenderer::class,
     ];
 
     /**
@@ -962,10 +960,11 @@ class Debugger
 
         $outputFormat = $this->_outputFormat;
         if (isset($this->renderers[$outputFormat])) {
+            /** @var array $trace */
             $trace = static::trace(['start' => $data['start'], 'format' => 'points']);
             $error = new PhpError($data['code'], $data['description'], $data['file'], $data['line'], $trace);
             $renderer = new $this->renderers[$outputFormat]();
-            echo $renderer->render($error);
+            echo $renderer->render($error, Configure::read('debug'));
 
             return;
         }

+ 1 - 1
src/Error/ErrorHandler.php

@@ -200,7 +200,7 @@ class ErrorHandler extends BaseErrorHandler
     /**
      * Method that can be easily stubbed in testing.
      *
-     * @param \Cake\Http\Response|string $response Either the message or response object.
+     * @param \Psr\Http\Message\ResponseInterface|string $response Either the message or response object.
      * @return void
      */
     protected function _sendResponse(Response|string $response): void

+ 10 - 1
src/Error/ErrorRendererInterface.php

@@ -28,7 +28,16 @@ interface ErrorRendererInterface
      * Render output for the provided error.
      *
      * @param \Cake\Error\PhpError $error The error to be rendered.
+     * @param bool $debug Whether or not the application is in debug mode.
      * @return string The output to be echoed.
      */
-    public function render(PhpError $error): string;
+    public function render(PhpError $error, bool $debug): string;
+
+    /**
+     * Write output to the renderer's output stream
+     *
+     * @param string $out The content to output.
+     * @return void
+     */
+    public function write(string $out): void;
 }

+ 211 - 0
src/Error/ErrorTrap.php

@@ -0,0 +1,211 @@
+<?php
+declare(strict_types=1);
+
+namespace Cake\Error;
+
+use Cake\Core\Configure;
+use Cake\Core\InstanceConfigTrait;
+use Cake\Error\Renderer\ConsoleErrorRenderer;
+use Cake\Error\Renderer\HtmlErrorRenderer;
+use Closure;
+use Exception;
+use InvalidArgumentException;
+
+/**
+ * Entry point to CakePHP's error handling.
+ *
+ * Using the `register()` method you can attach an ErrorTrap
+ * to PHP's default error handler. When errors are trapped
+ * they are 'rendered' using the defined renderers and logged
+ * if logging is enabled.
+ */
+class ErrorTrap
+{
+    use InstanceConfigTrait {
+        getConfig as private _getConfig;
+    }
+
+    /**
+     * See the `Error` key in you `config/app.php`
+     * for details on the keys and their values.
+     *
+     * @var array<string, mixed>
+     */
+    protected $_defaultConfig = [
+        'errorLevel' => E_ALL,
+        'ignoredDeprecationPaths' => [],
+        'log' => true,
+        'logger' => ErrorLogger::class,
+        'errorRenderer' => null,
+        'extraFatalErrorMemory' => 4 * 1024,
+        'trace' => false,
+    ];
+
+    /**
+     * A list of handling callbacks.
+     *
+     * Callbacks are invoked for each error that is handled.
+     * Callbacks are invoked in the order they are attached.
+     *
+     * @var array<\Closure>
+     */
+    protected $callbacks = [];
+
+    /**
+     * Constructor
+     *
+     * @param array<string, mixed> $options An options array. See $_defaultConfig.
+     */
+    public function __construct(array $options = [])
+    {
+        $this->setConfig($options);
+        if ($this->_getConfig('errorRenderer') === null) {
+            $this->setConfig('errorRenderer', $this->chooseErrorRenderer());
+        }
+    }
+
+    /**
+     * Choose an error renderer based on config or the SAPI
+     *
+     * @return class-string<\Cake\Error\ErrorRendererInterface>
+     */
+    protected function chooseErrorRenderer(): string
+    {
+        $config = $this->_getConfig('errorRenderer');
+        if ($config !== null) {
+            return $config;
+        }
+
+        /** @var class-string<\Cake\Error\ErrorRendererInterface> */
+        return PHP_SAPI === 'cli' ? ConsoleErrorRenderer::class : HtmlErrorRenderer::class;
+    }
+
+    /**
+     * Attach this ErrorTrap to PHP's default error handler.
+     *
+     * This will replace the existing error handler, and the
+     * previous error handler will be discarded.
+     *
+     * This method will also set the global error level
+     * via error_reporting().
+     *
+     * @return void
+     */
+    public function register(): void
+    {
+        $level = $this->_config['errorLevel'] ?? -1;
+        error_reporting($level);
+        set_error_handler([$this, 'handleError'], $level);
+    }
+
+    /**
+     * Handle an error from PHP set_error_handler
+     *
+     * Will use the configured renderer to generate output
+     * and output it.
+     *
+     * @param int $code Code of error
+     * @param string $description Error description
+     * @param string|null $file File on which error occurred
+     * @param int|null $line Line that triggered the error
+     * @return bool True if error was handled
+     */
+    public function handleError(
+        int $code,
+        string $description,
+        ?string $file = null,
+        ?int $line = null
+    ): bool {
+        if (!(error_reporting() & $code)) {
+            return false;
+        }
+        $debug = Configure::read('debug');
+        /** @var array $trace */
+        $trace = Debugger::trace(['start' => 1, 'format' => 'points']);
+        $error = new PhpError($code, $description, $file, $line, $trace);
+
+        $renderer = $this->renderer();
+        $logger = $this->logger();
+
+        try {
+            // Log first incase rendering or callbacks fail.
+            $logger->logMessage($error->getLabel(), $error->getMessage());
+
+            foreach ($this->callbacks as $callback) {
+                $callback($error);
+            }
+            $renderer->write($renderer->render($error, $debug));
+        } catch (Exception $e) {
+            $logger->logMessage('error', 'Could not render error. Got: ' . $e->getMessage());
+
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Get an instance of the renderer.
+     *
+     * @return \Cake\Error\ErrorRendererInterface
+     */
+    public function renderer(): ErrorRendererInterface
+    {
+        $class = $this->_getConfig('errorRenderer');
+        if (!$class) {
+            $class = $this->chooseErrorRenderer();
+        }
+        if (!in_array(ErrorRendererInterface::class, class_implements($class))) {
+            throw new InvalidArgumentException(
+                "Cannot use {$class} as an error renderer. It must implement \Cake\Error\ErrorRendererInterface."
+            );
+        }
+
+        /** @var \Cake\Error\ErrorRendererInterface $instance */
+        $instance = new $class($this->_config);
+
+        return $instance;
+    }
+
+    /**
+     * Get an instance of the logger.
+     *
+     * @return \Cake\Error\ErrorLoggerInterface
+     */
+    public function logger(): ErrorLoggerInterface
+    {
+        $class = $this->_getConfig('logger');
+        if (!$class) {
+            $class = $this->_defaultConfig['logger'];
+        }
+        if (!in_array(ErrorLoggerInterface::class, class_implements($class))) {
+            throw new InvalidArgumentException(
+                "Cannot use {$class} as an error logger. It must implement \Cake\Error\ErrorLoggerInterface."
+            );
+        }
+
+        /** @var \Cake\Error\ErrorLoggerInterface $instance */
+        $instance = new $class($this->_config);
+
+        return $instance;
+    }
+
+    /**
+     * Add a callback to be invoked when an error is handled.
+     *
+     * Your callback should habe the following signature:
+     *
+     * ```
+     * function (\Cake\Error\PhpError $error): void
+     * ```
+     *
+     * @param \Closure $closure The Closure to be invoked when an error is handledd.
+     * @return $this
+     */
+    public function addCallback(Closure $closure)
+    {
+        $this->callbacks[] = $closure;
+
+        return $this;
+    }
+}

+ 19 - 0
src/Error/ExceptionRenderer.php

@@ -31,6 +31,7 @@ use Cake\Event\Event;
 use Cake\Http\Exception\HttpException;
 use Cake\Http\Exception\MissingControllerException;
 use Cake\Http\Response;
+use Cake\Http\ResponseEmitter;
 use Cake\Http\ServerRequest;
 use Cake\Http\ServerRequestFactory;
 use Cake\Routing\Exception\MissingRouteException;
@@ -276,6 +277,24 @@ class ExceptionRenderer implements ExceptionRendererInterface
     }
 
     /**
+     * Emit the response content
+     *
+     * @param \Psr\Http\Message\ResponseInterface|string $output The response to output.
+     * @return void
+     */
+    public function write($output): void
+    {
+        if (is_string($output)) {
+            echo $output;
+
+            return;
+        }
+
+        $emitter = new ResponseEmitter();
+        $emitter->emit($output);
+    }
+
+    /**
      * Render a custom error method/template.
      *
      * @param string $method The method name to invoke.

+ 4 - 1
src/Error/ExceptionRendererInterface.php

@@ -20,13 +20,16 @@ use Psr\Http\Message\ResponseInterface;
 
 /**
  * Interface ExceptionRendererInterface
+ *
+ * @method \Psr\Http\Message\ResponseInterface|string render() Render the exception to a string or Http Response.
+ * @method void write(\Psr\Http\Message\ResponseInterface|string $output) Write the output to the output stream.
  */
 interface ExceptionRendererInterface
 {
     /**
      * Renders the response for the exception.
      *
-     * @return \Cake\Http\Response The response to be sent.
+     * @return \Psr\Http\Message\ResponseInterface The response to be sent.
      */
     public function render(): ResponseInterface;
 }

+ 228 - 0
src/Error/ExceptionTrap.php

@@ -0,0 +1,228 @@
+<?php
+declare(strict_types=1);
+
+namespace Cake\Error;
+
+use Cake\Core\InstanceConfigTrait;
+use Cake\Http\ServerRequest;
+use Cake\Routing\Router;
+use Closure;
+use InvalidArgumentException;
+use Throwable;
+
+/**
+ * Entry point to CakePHP's exception handling.
+ *
+ * Using the `register()` method you can attach an ExceptionTrap
+ * to PHP's default exception handler and register a shutdown
+ * handler to handle fatal errors. When exceptions are trapped
+ * they are 'rendered' using the defined renderers and logged
+ * if logging is enabled.
+ *
+ * Exceptions will be logged, then call attached callbacks
+ * and finally render an error page using the configured
+ * `exceptionRenderer`.
+ *
+ * If undefined, an ExceptionRenderer will be selected
+ * based on the current SAPI (CLI or Web).
+ */
+class ExceptionTrap
+{
+    use InstanceConfigTrait {
+        getConfig as private _getConfig;
+    }
+
+    /**
+     * See the `Error` key in you `config/app.php`
+     * for details on the keys and their values.
+     *
+     * @var array<string, mixed>
+     */
+    protected $_defaultConfig = [
+        'exceptionRenderer' => ExceptionRenderer::class,
+        'logger' => ErrorLogger::class,
+        // Used by ConsoleExceptionRenderer (coming soon)
+        'stderr' => null,
+        'log' => true,
+        'trace' => false,
+    ];
+
+    /**
+     * A list of handling callbacks.
+     *
+     * Callbacks are invoked for each error that is handled.
+     * Callbacks are invoked in the order they are attached.
+     *
+     * @var array<\Closure>
+     */
+    protected $callbacks = [];
+
+    /**
+     * Constructor
+     *
+     * @param array<string, mixed> $options An options array. See $_defaultConfig.
+     */
+    public function __construct(array $options = [])
+    {
+        $this->setConfig($options);
+    }
+
+    /**
+     * Get an instance of the renderer.
+     *
+     * @param \Throwable $exception Exception to render
+     * @return \Cake\Error\ExceptionRendererInterface
+     */
+    public function renderer(Throwable $exception)
+    {
+        // The return of this method is not defined because
+        // the desired interface has bad types that will be changing in 5.x
+        $request = Router::getRequest();
+        $class = $this->_getConfig('exceptionRenderer');
+
+        if (is_string($class)) {
+            if (!(method_exists($class, 'render') && method_exists($class, 'write'))) {
+                throw new InvalidArgumentException(
+                    "Cannot use {$class} as an `exceptionRenderer`. " .
+                    'It must implement render() and write() methods.'
+                );
+            }
+
+            /** @var \Cake\Error\ExceptionRendererInterface $instance */
+            $instance = new $class($exception, $request);
+
+            return $instance;
+        }
+
+        /** @var callable $factory */
+        $factory = $class;
+
+        return $factory($exception, $request);
+    }
+
+    /**
+     * Get an instance of the logger.
+     *
+     * @return \Cake\Error\ErrorLoggerInterface
+     */
+    public function logger(): ErrorLoggerInterface
+    {
+        $class = $this->_getConfig('logger');
+        if (!$class) {
+            $class = $this->_defaultConfig['logger'];
+        }
+        if (!in_array(ErrorLoggerInterface::class, class_implements($class))) {
+            throw new InvalidArgumentException(
+                "Cannot use {$class} as an exception logger. " .
+                "It must implement \Cake\Error\ErrorLoggerInterface."
+            );
+        }
+
+        /** @var \Cake\Error\ErrorLoggerInterface $instance */
+        $instance = new $class($this->_config);
+
+        return $instance;
+    }
+
+    /**
+     * Add a callback to be invoked when an error is handled.
+     *
+     * Your callback should habe the following signature:
+     *
+     * ```
+     * function (\Throwable $error): void
+     * ```
+     *
+     * @param \Closure $closure The Closure to be invoked when an error is handledd.
+     * @return $this
+     */
+    public function addCallback(Closure $closure)
+    {
+        $this->callbacks[] = $closure;
+
+        return $this;
+    }
+
+    /**
+     * Attach this ExceptionTrap to PHP's default exception handler.
+     *
+     * This will replace the existing exception handler, and the
+     * previous exception handler will be discarded.
+     *
+     * @return void
+     */
+    public function register(): void
+    {
+        set_exception_handler([$this, 'handleException']);
+        // TODO handle fatal errors.
+    }
+
+    /**
+     * Handle uncaught exceptions.
+     *
+     * Uses a template method provided by subclasses to display errors in an
+     * environment appropriate way.
+     *
+     * @param \Throwable $exception Exception instance.
+     * @return void
+     * @throws \Exception When renderer class not found
+     * @see https://secure.php.net/manual/en/function.set-exception-handler.php
+     */
+    public function handleException(Throwable $exception): void
+    {
+        $request = Router::getRequest();
+
+        $this->logException($exception, $request);
+        foreach ($this->callbacks as $callback) {
+            $callback($exception);
+        }
+
+        try {
+            $renderer = $this->renderer($exception);
+            $renderer->write($renderer->render());
+        } catch (Throwable $exception) {
+            $this->logInternalError($exception);
+        }
+    }
+
+    /**
+     * Log an exception.
+     *
+     * Primarily a public function to ensure consistency between global exception handling
+     * and the ErrorHandlerMiddleware
+     *
+     * @param \Throwable $exception The exception to log
+     * @param \Cake\Http\ServerRequest|null $request The optional request
+     * @return void
+     */
+    public function logException(Throwable $exception, ?ServerRequest $request = null): void
+    {
+        $logger = $this->logger();
+        $logger->log($exception, $request);
+    }
+
+    /**
+     * Trigger an error that occurred during rendering an exception.
+     *
+     * By triggering an E_USER_ERROR we can end up in the default
+     * exception handling which will log the rendering failure,
+     * and hopefully render an error page.
+     *
+     * @param \Throwable $exception Exception to log
+     * @return void
+     */
+    public function logInternalError(Throwable $exception): void
+    {
+        // Disable trace for internal errors.
+        $this->_config['trace'] = false;
+        $message = sprintf(
+            "[%s] %s (%s:%s)\n%s", // Keeping same message format
+            get_class($exception),
+            $exception->getMessage(),
+            $exception->getFile(),
+            $exception->getLine(),
+            $exception->getTraceAsString()
+        );
+        trigger_error($message, E_USER_ERROR);
+    }
+}

+ 1 - 1
src/Error/PhpError.php

@@ -121,7 +121,7 @@ class PhpError
     {
         $label = $this->getLabel();
 
-        return $this->logMap[$label] ?? 'error';
+        return $this->logMap[$label] ?? LOG_ERR;
     }
 
     /**

+ 53 - 0
src/Error/Renderer/ConsoleErrorRenderer.php

@@ -0,0 +1,53 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         4.4.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Error\Renderer;
+
+use Cake\Error\ErrorRendererInterface;
+use Cake\Error\PhpError;
+
+/**
+ * Plain text error rendering with a stack trace.
+ *
+ * Writes to STDERR for console environments
+ */
+class ConsoleErrorRenderer implements ErrorRendererInterface
+{
+    /**
+     * @inheritDoc
+     */
+    public function write(string $out): void
+    {
+        // Write to stderr which is useful in console environments.
+        fwrite(STDERR, $out);
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function render(PhpError $error, bool $debug): string
+    {
+        return sprintf(
+            "%s: %s :: %s on line %s of %s\nTrace:\n%s",
+            $error->getLabel(),
+            $error->getCode(),
+            $error->getMessage(),
+            $error->getLine() ?? '',
+            $error->getFile() ?? '',
+            $error->getTraceAsString(),
+        );
+    }
+}

+ 22 - 6
src/Error/Renderer/HtmlRenderer.php

@@ -25,22 +25,34 @@ use Cake\Error\PhpError;
  *
  * Default output renderer for non CLI SAPI.
  */
-class HtmlRenderer implements ErrorRendererInterface
+class HtmlErrorRenderer implements ErrorRendererInterface
 {
     /**
      * @inheritDoc
      */
-    public function render(PhpError $error): string
+    public function write(string $out): void
     {
+        // Output to stdout which is the server response.
+        echo $out;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function render(PhpError $error, bool $debug): string
+    {
+        if (!$debug) {
+            return '';
+        }
         $id = 'cakeErr' . uniqid();
+        $file = $error->getFile();
 
         // Some of the error data is not HTML safe so we escape everything.
         $description = h($error->getMessage());
-        $path = h($error->getFile());
-        $line = h($error->getLine());
+        $path = h($file);
         $trace = h($error->getTraceAsString());
+        $line = $error->getLine();
 
-        debug($error);
         $errorMessage = sprintf(
             '<b>%s</b> (%s)',
             h(ucfirst($error->getLabel())),
@@ -48,7 +60,11 @@ class HtmlRenderer implements ErrorRendererInterface
         );
         $toggle = $this->renderToggle($errorMessage, $id, 'trace');
         $codeToggle = $this->renderToggle('Code', $id, 'code');
-        $excerpt = Debugger::excerpt($error->getFile(), $error->getLine(), 1);
+
+        $excerpt = [];
+        if ($file && $line) {
+            $excerpt = Debugger::excerpt($file, $line, 1);
+        }
         $code = implode("\n", $excerpt);
 
         $html = <<<HTML

+ 17 - 5
src/Error/Renderer/TextRenderer.php

@@ -22,22 +22,34 @@ use Cake\Error\PhpError;
 /**
  * Plain text error rendering with a stack trace.
  *
- * Useful in CLI and log file contexts.
+ * Useful in CLI environments.
  */
-class TextRenderer implements ErrorRendererInterface
+class TextErrorRenderer implements ErrorRendererInterface
 {
     /**
      * @inheritDoc
      */
-    public function render(PhpError $error): string
+    public function write(string $out): void
     {
+        echo $out;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function render(PhpError $error, bool $debug): string
+    {
+        if (!$debug) {
+            return '';
+        }
+
         return sprintf(
             "%s: %s :: %s on line %s of %s\nTrace:\n%s",
             $error->getLabel(),
             $error->getCode(),
             $error->getMessage(),
-            $error->getLine(),
-            $error->getFile(),
+            $error->getLine() ?? '',
+            $error->getFile() ?? '',
             $error->getTraceAsString(),
         );
     }

+ 73 - 0
src/Error/Renderer/TextExceptionRenderer.php

@@ -0,0 +1,73 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP(tm) Project
+ * @since         4.4.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Error\Renderer;
+
+use Throwable;
+
+/**
+ * Plain text exception rendering with a stack trace.
+ *
+ * Useful in CI or plain text environments.
+ *
+ * @todo 5.0 Implement \Cake\Error\ExceptionRendererInterface. This implementation can't implement
+ *  the concrete interface because the return types are not compatible.
+ */
+class TextExceptionRenderer
+{
+    /**
+     * @var \Throwable
+     */
+    private $error;
+
+    /**
+     * Constructor.
+     *
+     * @param \Throwable $error The error to render.
+     */
+    public function __construct(Throwable $error)
+    {
+        $this->error = $error;
+    }
+
+    /**
+     * Render an exception into a plain text message.
+     *
+     * @return \Psr\Http\Message\ResponseInterface|string
+     */
+    public function render()
+    {
+        return sprintf(
+            "%s : %s on line %s of %s\nTrace:\n%s",
+            $this->error->getCode(),
+            $this->error->getMessage(),
+            $this->error->getLine(),
+            $this->error->getFile(),
+            $this->error->getTraceAsString(),
+        );
+    }
+
+    /**
+     * Write output to stdout.
+     *
+     * @param string $output The output to print.
+     * @return void
+     */
+    public function write($output): void
+    {
+        echo $output;
+    }
+}

+ 1 - 1
src/Log/composer.json

@@ -28,7 +28,7 @@
         "psr/log": "^3.0"
     },
     "provide": {
-        "psr/log-implementation": "^1.0.0"
+        "psr/log-implementation": "^3.0"
     },
     "autoload": {
         "psr-4": {

+ 56 - 18
src/TestSuite/Fixture/FixtureHelper.php

@@ -17,6 +17,7 @@ declare(strict_types=1);
 namespace Cake\TestSuite\Fixture;
 
 use Cake\Core\Configure;
+use Cake\Core\Exception\CakeException;
 use Cake\Database\Connection;
 use Cake\Database\DriverInterface;
 use Cake\Database\Schema\TableSchema;
@@ -25,6 +26,7 @@ use Cake\Datasource\ConnectionManager;
 use Cake\Datasource\FixtureInterface;
 use Cake\TestSuite\ConnectionHelper;
 use Closure;
+use PDOException;
 use UnexpectedValueException;
 
 /**
@@ -135,29 +137,47 @@ class FixtureHelper
             if ($connection instanceof Connection) {
                 $sortedFixtures = $this->sortByConstraint($connection, $groupFixtures);
                 if ($sortedFixtures) {
-                    foreach ($sortedFixtures as $fixture) {
-                        $fixture->insert($connection);
-                    }
+                    $this->insertConnection($connection, $sortedFixtures);
                 } else {
                     $helper = new ConnectionHelper();
                     $helper->runWithoutConstraints(
                         $connection,
                         function (Connection $connection) use ($groupFixtures): void {
-                            foreach ($groupFixtures as $fixture) {
-                                $fixture->insert($connection);
-                            }
+                            $this->insertConnection($connection, $groupFixtures);
                         }
                     );
                 }
             } else {
-                foreach ($groupFixtures as $fixture) {
-                    $fixture->insert($connection);
-                }
+                $this->insertConnection($connection, $groupFixtures);
             }
         }, $fixtures);
     }
 
     /**
+     * Inserts all fixtures for a connection and provides friendly errors for bad data.
+     *
+     * @param \Cake\Datasource\ConnectionInterface $connection Fixture connection
+     * @param array<\Cake\Datasource\FixtureInterface> $fixtures Connection fixtures
+     * @return void
+     */
+    protected function insertConnection(ConnectionInterface $connection, array $fixtures): void
+    {
+        foreach ($fixtures as $fixture) {
+            try {
+                $fixture->insert($connection);
+            } catch (PDOException $exception) {
+                $message = sprintf(
+                    'Unable to insert rows for table `%s`.'
+                        . " Fixture records might have invalid data or unknown contraints.\n%s",
+                    $fixture->sourceName(),
+                    $exception->getMessage()
+                );
+                throw new CakeException($message);
+            }
+        }
+    }
+
+    /**
      * Truncates fixture tables.
      *
      * @param array<\Cake\Datasource\FixtureInterface> $fixtures Test fixtures
@@ -174,29 +194,47 @@ class FixtureHelper
                 }
 
                 if ($sortedFixtures !== null) {
-                    foreach (array_reverse($sortedFixtures) as $fixture) {
-                        $fixture->truncate($connection);
-                    }
+                    $this->truncateConnection($connection, array_reverse($sortedFixtures));
                 } else {
                     $helper = new ConnectionHelper();
                     $helper->runWithoutConstraints(
                         $connection,
                         function (Connection $connection) use ($groupFixtures): void {
-                            foreach ($groupFixtures as $fixture) {
-                                $fixture->truncate($connection);
-                            }
+                            $this->truncateConnection($connection, $groupFixtures);
                         }
                     );
                 }
             } else {
-                foreach ($groupFixtures as $fixture) {
-                    $fixture->truncate($connection);
-                }
+                $this->truncateConnection($connection, $groupFixtures);
             }
         }, $fixtures);
     }
 
     /**
+     * Truncates all fixtures for a connection and provides friendly errors for bad data.
+     *
+     * @param \Cake\Datasource\ConnectionInterface $connection Fixture connection
+     * @param array<\Cake\Datasource\FixtureInterface> $fixtures Connection fixtures
+     * @return void
+     */
+    protected function truncateConnection(ConnectionInterface $connection, array $fixtures): void
+    {
+        foreach ($fixtures as $fixture) {
+            try {
+                $fixture->truncate($connection);
+            } catch (PDOException $exception) {
+                $message = sprintf(
+                    'Unable to truncate table `%s`.'
+                        . " Fixture records might have invalid data or unknown contraints.\n%s",
+                    $fixture->sourceName(),
+                    $exception->getMessage()
+                );
+                throw new CakeException($message);
+            }
+        }
+    }
+
+    /**
      * Sort fixtures with foreign constraints last if possible, otherwise returns null.
      *
      * @param \Cake\Database\Connection $connection Database connection

+ 4 - 5
src/View/Form/EntityContext.php

@@ -125,8 +125,11 @@ class EntityContext implements ContextInterface
         $table = $this->_context['table'];
         /** @var \Cake\Datasource\EntityInterface|iterable $entity */
         $entity = $this->_context['entity'];
+
+        $this->_isCollection = is_iterable($entity);
+
         if (empty($table)) {
-            if (is_iterable($entity)) {
+            if ($this->_isCollection) {
                 foreach ($entity as $e) {
                     $entity = $e;
                     break;
@@ -152,10 +155,6 @@ class EntityContext implements ContextInterface
                 'Unable to find table class for current entity.'
             );
         }
-        $this->_isCollection = (
-            is_array($entity) ||
-            $entity instanceof Traversable
-        );
 
         $alias = $this->_rootName = $table->getAlias();
         $this->_tables[$alias] = $table;

+ 9 - 8
src/View/Helper/FormHelper.php

@@ -1481,8 +1481,8 @@ class FormHelper extends Helper
      *
      * - `value` - the value of the checkbox
      * - `checked` - boolean indicate that this checkbox is checked.
-     * - `hiddenField` - boolean to indicate if you want the results of checkbox() to include
-     *    a hidden input with a value of ''.
+     * - `hiddenField` - boolean|string. Set to false to disable a hidden input from
+     *    being generated. Passing a string will define the hidden input value.
      * - `disabled` - create a disabled input.
      * - `default` - Set the default value for the checkbox. This allows you to start checkboxes
      *    as checked, without having to check the POST data. A matching POST data value, will overwrite
@@ -1504,12 +1504,12 @@ class FormHelper extends Helper
         $options['value'] = $value;
 
         $output = '';
-        if ($options['hiddenField']) {
+        if ($options['hiddenField'] !== false && is_scalar($options['hiddenField'])) {
             $hiddenOptions = [
                 'name' => $options['name'],
                 'value' => $options['hiddenField'] !== true
                     && $options['hiddenField'] !== '_split'
-                    ? $options['hiddenField'] : '0',
+                    ? (string)$options['hiddenField'] : '0',
                 'form' => $options['form'] ?? null,
                 'secure' => false,
             ];
@@ -1538,8 +1538,9 @@ class FormHelper extends Helper
      * - `label` - Either `false` to disable label around the widget or an array of attributes for
      *    the label tag. `selected` will be added to any classes e.g. `'class' => 'myclass'` where widget
      *    is checked
-     * - `hiddenField` - boolean to indicate if you want the results of radio() to include
-     *    a hidden input with a value of ''. This is useful for creating radio sets that are non-continuous.
+     * - `hiddenField` - boolean|string. Set to false to not include a hidden input with a value of ''.
+     *    Can also be a string to set the value of the hidden input. This is useful for creating
+     *    radio sets that are non-continuous.
      * - `disabled` - Set to `true` or `disabled` to disable all the radio buttons. Use an array of
      *   values to disable specific radio buttons.
      * - `empty` - Set to `true` to create an input with the value '' as the first option. When `true`
@@ -1563,9 +1564,9 @@ class FormHelper extends Helper
         $radio = $this->widget('radio', $attributes);
 
         $hidden = '';
-        if ($hiddenField) {
+        if ($hiddenField !== false && is_scalar($hiddenField)) {
             $hidden = $this->hidden($fieldName, [
-                'value' => $hiddenField === true ? '' : $hiddenField,
+                'value' => $hiddenField === true ? '' : (string)$hiddenField,
                 'form' => $attributes['form'] ?? null,
                 'name' => $attributes['name'],
             ]);

+ 11 - 0
tests/TestCase/Core/AppTest.php

@@ -74,6 +74,17 @@ class AppTest extends TestCase
     }
 
     /**
+     * @link https://github.com/cakephp/cakephp/issues/16258
+     */
+    public function testClassNameWithAppNamespaceUnset(): void
+    {
+        Configure::delete('App.namespace');
+
+        $result = App::className('Mysql', 'Database/Driver');
+        $this->assertSame(Mysql::class, $result);
+    }
+
+    /**
      * testShortName
      *
      * @param string $class Class name

+ 2 - 2
tests/TestCase/Error/DebuggerTest.php

@@ -25,7 +25,7 @@ use Cake\Error\Debug\ScalarNode;
 use Cake\Error\Debug\SpecialNode;
 use Cake\Error\Debug\TextFormatter;
 use Cake\Error\Debugger;
-use Cake\Error\Renderer\HtmlRenderer;
+use Cake\Error\Renderer\HtmlErrorRenderer;
 use Cake\Form\Form;
 use Cake\Log\Log;
 use Cake\TestSuite\TestCase;
@@ -193,7 +193,7 @@ class DebuggerTest extends TestCase
      */
     public function testAddOutputFormatOverwrite(): void
     {
-        Debugger::addRenderer('test', HtmlRenderer::class);
+        Debugger::addRenderer('test', HtmlErrorRenderer::class);
         Debugger::addFormat('test', [
             'error' => '{:description} : {:path}, line {:line}',
         ]);

+ 148 - 0
tests/TestCase/Error/ErrorTrapTest.php

@@ -0,0 +1,148 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP Project
+ * @since         4.4.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase\Error;
+
+use Cake\Core\Configure;
+use Cake\Error\ErrorLogger;
+use Cake\Error\ErrorTrap;
+use Cake\Error\PhpError;
+use Cake\Error\Renderer\ConsoleErrorRenderer;
+use Cake\Error\Renderer\HtmlErrorRenderer;
+use Cake\Error\Renderer\TextErrorRenderer;
+use Cake\Log\Log;
+use Cake\TestSuite\TestCase;
+use InvalidArgumentException;
+use stdClass;
+
+class ErrorTrapTest extends TestCase
+{
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        Log::drop('test_error');
+    }
+
+    public function testConfigRendererInvalid()
+    {
+        $trap = new ErrorTrap(['errorRenderer' => stdClass::class]);
+        $this->expectException(InvalidArgumentException::class);
+        $trap->renderer();
+    }
+
+    public function testConfigErrorRendererFallback()
+    {
+        $trap = new ErrorTrap(['errorRenderer' => null]);
+        $this->assertInstanceOf(ConsoleErrorRenderer::class, $trap->renderer());
+    }
+
+    public function testConfigErrorRenderer()
+    {
+        $trap = new ErrorTrap(['errorRenderer' => HtmlErrorRenderer::class]);
+        $this->assertInstanceOf(HtmlErrorRenderer::class, $trap->renderer());
+    }
+
+    public function testConfigRendererHandleUnsafeOverwrite()
+    {
+        $trap = new ErrorTrap();
+        $trap->setConfig('errorRenderer', null);
+        $this->assertInstanceOf(ConsoleErrorRenderer::class, $trap->renderer());
+    }
+
+    public function testLoggerConfigInvalid()
+    {
+        $trap = new ErrorTrap(['logger' => stdClass::class]);
+        $this->expectException(InvalidArgumentException::class);
+        $trap->logger();
+    }
+
+    public function testLoggerConfig()
+    {
+        $trap = new ErrorTrap(['logger' => ErrorLogger::class]);
+        $this->assertInstanceOf(ErrorLogger::class, $trap->logger());
+    }
+
+    public function testLoggerHandleUnsafeOverwrite()
+    {
+        $trap = new ErrorTrap();
+        $trap->setConfig('logger', null);
+        $this->assertInstanceOf(ErrorLogger::class, $trap->logger());
+    }
+
+    public function testRegisterAndRendering()
+    {
+        $trap = new ErrorTrap(['errorRenderer' => TextErrorRenderer::class]);
+        $trap->register();
+        ob_start();
+        trigger_error('Oh no it was bad', E_USER_NOTICE);
+        $output = ob_get_clean();
+        restore_error_handler();
+
+        $this->assertStringContainsString('Oh no it was bad', $output);
+    }
+
+    public function testRegisterAndLogging()
+    {
+        Log::setConfig('test_error', [
+            'className' => 'Array',
+        ]);
+        $trap = new ErrorTrap([
+            'errorRenderer' => TextErrorRenderer::class,
+        ]);
+        $trap->register();
+
+        ob_start();
+        trigger_error('Oh no it was bad', E_USER_NOTICE);
+        ob_get_clean();
+        restore_error_handler();
+
+        $logs = Log::engine('test_error')->read();
+        $this->assertStringContainsString('Oh no it was bad', $logs[0]);
+    }
+
+    public function testRegisterNoOutputDebug()
+    {
+        Log::setConfig('test_error', [
+            'className' => 'Array',
+        ]);
+        Configure::write('debug', false);
+        $trap = new ErrorTrap(['errorRenderer' => TextErrorRenderer::class]);
+        $trap->register();
+
+        ob_start();
+        trigger_error('Oh no it was bad', E_USER_NOTICE);
+        $output = ob_get_clean();
+        restore_error_handler();
+        $this->assertSame('', $output);
+    }
+
+    public function testAddCallback()
+    {
+        $trap = new ErrorTrap(['errorRenderer' => TextErrorRenderer::class]);
+        $trap->register();
+        $trap->addCallback(function (PhpError $error) {
+            $this->assertEquals(E_USER_NOTICE, $error->getCode());
+            $this->assertStringContainsString('Oh no it was bad', $error->getMessage());
+        });
+
+        ob_start();
+        trigger_error('Oh no it was bad', E_USER_NOTICE);
+        $out = ob_get_clean();
+        restore_error_handler();
+        $this->assertNotEmpty($out);
+    }
+}

+ 167 - 0
tests/TestCase/Error/ExceptionTrapTest.php

@@ -0,0 +1,167 @@
+<?php
+declare(strict_types=1);
+
+/**
+ * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
+ * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ *
+ * Licensed under The MIT License
+ * For full copyright and license information, please see the LICENSE.txt
+ * Redistributions of files must retain the above copyright notice.
+ *
+ * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
+ * @link          https://cakephp.org CakePHP Project
+ * @since         4.4.0
+ * @license       https://opensource.org/licenses/mit-license.php MIT License
+ */
+namespace Cake\Test\TestCase\Error;
+
+use Cake\Error\ErrorLogger;
+use Cake\Error\ExceptionRenderer;
+use Cake\Error\ExceptionTrap;
+use Cake\Error\Renderer\TextExceptionRenderer;
+use Cake\Log\Log;
+use Cake\TestSuite\TestCase;
+use InvalidArgumentException;
+use stdClass;
+use Throwable;
+
+class ExceptionTrapTest extends TestCase
+{
+    public function setUp(): void
+    {
+        parent::setUp();
+
+        Log::drop('test_error');
+    }
+
+    public function testConfigRendererInvalid()
+    {
+        $trap = new ExceptionTrap(['exceptionRenderer' => stdClass::class]);
+        $this->expectException(InvalidArgumentException::class);
+        $error = new InvalidArgumentException('nope');
+        $trap->renderer($error);
+    }
+
+    public function testConfigExceptionRendererFallback()
+    {
+        $this->markTestIncomplete();
+        $trap = new ExceptionTrap(['exceptionRenderer' => null]);
+        $error = new InvalidArgumentException('nope');
+        $this->assertInstanceOf(ConsoleRenderer::class, $trap->renderer($error));
+    }
+
+    public function testConfigExceptionRenderer()
+    {
+        $trap = new ExceptionTrap(['exceptionRenderer' => ExceptionRenderer::class]);
+        $error = new InvalidArgumentException('nope');
+        $this->assertInstanceOf(ExceptionRenderer::class, $trap->renderer($error));
+    }
+
+    public function testConfigExceptionRendererFactory()
+    {
+        $trap = new ExceptionTrap(['exceptionRenderer' => function ($err, $req) {
+            return new ExceptionRenderer($err, $req);
+        }]);
+        $error = new InvalidArgumentException('nope');
+        $this->assertInstanceOf(ExceptionRenderer::class, $trap->renderer($error));
+    }
+
+    public function testConfigRendererHandleUnsafeOverwrite()
+    {
+        $this->markTestIncomplete();
+        $trap = new ExceptionTrap();
+        $trap->setConfig('exceptionRenderer', null);
+        $error = new InvalidArgumentException('nope');
+        $this->assertInstanceOf(ConsoleRenderer::class, $trap->renderer($error));
+    }
+
+    public function testLoggerConfigInvalid()
+    {
+        $trap = new ExceptionTrap(['logger' => stdClass::class]);
+        $this->expectException(InvalidArgumentException::class);
+        $trap->logger();
+    }
+
+    public function testLoggerConfig()
+    {
+        $trap = new ExceptionTrap(['logger' => ErrorLogger::class]);
+        $this->assertInstanceOf(ErrorLogger::class, $trap->logger());
+    }
+
+    public function testLoggerHandleUnsafeOverwrite()
+    {
+        $trap = new ExceptionTrap();
+        $trap->setConfig('logger', null);
+        $this->assertInstanceOf(ErrorLogger::class, $trap->logger());
+    }
+
+    public function testRenderExceptionText()
+    {
+        $trap = new ExceptionTrap([
+            'exceptionRenderer' => TextExceptionRenderer::class,
+        ]);
+        $error = new InvalidArgumentException('nope');
+
+        ob_start();
+        $trap->handleException($error);
+        $out = ob_get_clean();
+
+        $this->assertStringContainsString('nope', $out);
+        $this->assertStringContainsString('ExceptionTrapTest', $out);
+    }
+
+    /**
+     * Test integration with HTML exception rendering
+     *
+     * Run in a separate process because HTML output writes headers.
+     *
+     * @preserveGlobalState disabled
+     * @runInSeparateProcess
+     */
+    public function testRenderExceptionHtml()
+    {
+        $trap = new ExceptionTrap([
+            'exceptionRenderer' => ExceptionRenderer::class,
+        ]);
+        $error = new InvalidArgumentException('nope');
+
+        ob_start();
+        $trap->handleException($error);
+        $out = ob_get_clean();
+
+        $this->assertStringContainsString('<!DOCTYPE', $out);
+        $this->assertStringContainsString('<html', $out);
+        $this->assertStringContainsString('nope', $out);
+        $this->assertStringContainsString('ExceptionTrapTest', $out);
+    }
+
+    public function testLogException()
+    {
+        Log::setConfig('test_error', [
+            'className' => 'Array',
+        ]);
+        $trap = new ExceptionTrap();
+        $error = new InvalidArgumentException('nope');
+        $trap->logException($error);
+
+        $logs = Log::engine('test_error')->read();
+        $this->assertStringContainsString('nope', $logs[0]);
+    }
+
+    public function testAddCallback()
+    {
+        $trap = new ExceptionTrap(['exceptionRenderer' => TextExceptionRenderer::class]);
+        $trap->addCallback(function (Throwable $error) {
+            $this->assertEquals(100, $error->getCode());
+            $this->assertStringContainsString('nope', $error->getMessage());
+        });
+        $error = new InvalidArgumentException('nope', 100);
+
+        ob_start();
+        $trap->handleException($error);
+        $out = ob_get_clean();
+
+        $this->assertNotEmpty($out);
+    }
+}

+ 4 - 0
tests/TestCase/I18n/NumberTest.php

@@ -470,6 +470,10 @@ class NumberTest extends TestCase
         $result = $this->Number->toPercentage(0.13, 0, ['locale' => 'fi_FI', 'multiply' => true]);
         $expected = '13 %';
         $this->assertSame($expected, $result);
+
+        $result = $this->Number->toPercentage('0.13', 0, ['locale' => 'fi_FI', 'multiply' => true]);
+        $expected = '13 %';
+        $this->assertSame($expected, $result);
     }
 
     /**

+ 52 - 0
tests/TestCase/TestSuite/Fixture/FixtureHelperTest.php

@@ -16,12 +16,14 @@ declare(strict_types=1);
  */
 namespace Cake\Test\TestCase\TestSuite;
 
+use Cake\Core\Exception\CakeException;
 use Cake\Datasource\ConnectionManager;
 use Cake\Test\Fixture\ArticlesFixture;
 use Cake\TestSuite\Fixture\FixtureHelper;
 use Cake\TestSuite\Fixture\TestFixture;
 use Cake\TestSuite\TestCase;
 use Company\TestPluginThree\Test\Fixture\ArticlesFixture as CompanyArticlesFixture;
+use PDOException;
 use TestApp\Test\Fixture\ArticlesFixture as AppArticlesFixture;
 use TestPlugin\Test\Fixture\ArticlesFixture as PluginArticlesFixture;
 use TestPlugin\Test\Fixture\Blog\CommentsFixture as PluginCommentsFixture;
@@ -131,6 +133,31 @@ class FixtureHelperTest extends TestCase
     }
 
     /**
+     * Tests handling PDO errors when inserting rows.
+     */
+    public function testInsertFixturesException(): void
+    {
+        $fixture = $this->getMockBuilder(TestFixture::class)->getMock();
+        $fixture->expects($this->once())
+            ->method('connection')
+            ->will($this->returnValue('test'));
+        $fixture->expects($this->once())
+            ->method('insert')
+            ->will($this->throwException(new PDOException('Missing key')));
+
+        $helper = $this->getMockBuilder(FixtureHelper::class)
+            ->onlyMethods(['sortByConstraint'])
+            ->getMock();
+        $helper->expects($this->any())
+            ->method('sortByConstraint')
+            ->will($this->returnValue([$fixture]));
+
+        $this->expectException(CakeException::class);
+        $this->expectExceptionMessage('Unable to insert rows for table ``');
+        $helper->insert([$fixture]);
+    }
+
+    /**
      * Tests truncating fixtures.
      */
     public function testTruncateFixtures(): void
@@ -149,4 +176,29 @@ class FixtureHelperTest extends TestCase
         $this->assertEmpty($rows->fetchAll());
         $rows->closeCursor();
     }
+
+    /**
+     * Tests handling PDO errors when trucating rows.
+     */
+    public function testTruncateFixturesException(): void
+    {
+        $fixture = $this->getMockBuilder(TestFixture::class)->getMock();
+        $fixture->expects($this->once())
+            ->method('connection')
+            ->will($this->returnValue('test'));
+        $fixture->expects($this->once())
+            ->method('truncate')
+            ->will($this->throwException(new PDOException('Missing key')));
+
+        $helper = $this->getMockBuilder(FixtureHelper::class)
+            ->onlyMethods(['sortByConstraint'])
+            ->getMock();
+        $helper->expects($this->any())
+            ->method('sortByConstraint')
+            ->will($this->returnValue([$fixture]));
+
+        $this->expectException(CakeException::class);
+        $this->expectExceptionMessage('Unable to truncate table ``');
+        $helper->truncate([$fixture]);
+    }
 }

+ 2 - 0
tests/TestCase/View/Form/EntityContextTest.php

@@ -236,6 +236,8 @@ class EntityContextTest extends TestCase
 
         $result = $context->error('1.body');
         $this->assertEquals(['Not long enough'], $result);
+
+        $this->assertNull($context->val('0'));
     }
 
     /**

+ 48 - 0
tests/TestCase/View/Helper/FormHelperTest.php

@@ -4558,6 +4558,16 @@ class FormHelperTest extends TestCase
             '/label',
         ];
         $this->assertHtml($expected, $result);
+
+        $result = $this->Form->radio('title', ['option A'], ['hiddenField' => '']);
+        $expected = [
+            ['input' => ['type' => 'hidden', 'name' => 'title', 'value' => '']],
+            'label' => ['for' => 'title-0'],
+            ['input' => ['type' => 'radio', 'name' => 'title', 'value' => '0', 'id' => 'title-0']],
+            'option A',
+            '/label',
+        ];
+        $this->assertHtml($expected, $result);
     }
 
     /**
@@ -5074,6 +5084,28 @@ class FormHelperTest extends TestCase
             '/div',
         ];
         $this->assertHtml($expected, $result);
+
+        $result = $this->Form->control('User.get_spam', [
+            'type' => 'checkbox',
+            'value' => '0',
+            'hiddenField' => '',
+        ]);
+        $expected = [
+            'div' => ['class' => 'input checkbox'],
+            'label' => ['for' => 'user-get-spam'],
+            ['input' => [
+                'type' => 'hidden', 'name' => 'User[get_spam]',
+                'value' => '',
+            ]],
+            ['input' => [
+                'type' => 'checkbox', 'name' => 'User[get_spam]',
+                'value' => '0', 'id' => 'user-get-spam',
+            ]],
+            'Get Spam',
+            '/label',
+            '/div',
+        ];
+        $this->assertHtml($expected, $result);
     }
 
     /**
@@ -5776,6 +5808,22 @@ class FormHelperTest extends TestCase
 
         $result = $this->Form->checkbox('UserForm.something', [
             'value' => 'Y',
+            'hiddenField' => '',
+        ]);
+        $expected = [
+            ['input' => [
+                'type' => 'hidden', 'name' => 'UserForm[something]',
+                'value' => '',
+            ]],
+            ['input' => [
+                'type' => 'checkbox', 'name' => 'UserForm[something]',
+                'value' => 'Y',
+            ]],
+        ];
+        $this->assertHtml($expected, $result);
+
+        $result = $this->Form->checkbox('UserForm.something', [
+            'value' => 'Y',
             'hiddenField' => 'N',
         ]);
         $expected = [