*/ private array $store; public function __construct() { parent::__construct(); $this->stub = new PermissiveStub(); $this->store['loaded_assets'] = []; $this['filesystem'] = new \Symfony\Component\Filesystem\Filesystem(); } #[\Override] public function offsetExists(mixed $offset): bool { return true; } #[\Override] public function offsetGet(mixed $offset): mixed { if ($offset === 'url') { return new class { public function link(string $path, ?array $query = null): string { return ''; } public function adminLink(string $path, ?array $query = null): string { return ''; } }; } // The LegacyExtension's `ip_country_name`/`ip_country_code` filters // call `$this->di['geoip']->country($ip)` and then read properties on // the returned record. The signatures are `: string`, so a permissive // stub would fail with a TypeError. Provide a stub object whose // `country()` call throws — the surrounding try/catch then returns // the empty-string fallback the real code emits when GeoIP is // unavailable. Mirrors the same handling in `PermissiveContainer`. if ($offset === 'geoip') { return new class { public function __call(string $name, array $args): mixed { throw new \RuntimeException('geoip service not available in test environment'); } public function __get(string $name): mixed { throw new \RuntimeException('geoip service not available in test environment'); } }; } if (array_key_exists((string) $offset, $this->store)) { return $this->store[(string) $offset]; } return $this->stub; } #[\Override] public function offsetSet(mixed $offset, mixed $value): void { $this->store[(string) $offset] = $value; } } /** * Helper that builds a strict-variables Twig environment and renders every template * with a permissive stub context, capturing any rendering errors. * * Used by StrictVariablesTest to enforce that all templates are safe to render under * `strict_variables => true`. The permissive stub absorbs every undefined variable, attribute, * method, and array key, so what remains is structural errors: syntax errors, missing macros, * missing parents/blocks, undefined filters/functions, and runtime errors raised by filters. */ final class StrictTemplateRenderer { private function suppressNoticesAndWarnings(int $reportingLevel): int { return $reportingLevel & ~E_NOTICE & ~E_WARNING; } /** * @param array $contextOverrides */ public function renderTemplate(string $templatePath, array $contextOverrides = [], bool $emailMode = false): string { $previousLevel = error_reporting(); error_reporting($this->suppressNoticesAndWarnings($previousLevel)); try { $twig = $this->buildEnvironment($emailMode, stringifyUrls: true); $context = array_replace($this->buildContext($emailMode), $contextOverrides); try { $context = $this->enrichContextWithTemplateVariables($twig, $templatePath, $context, $emailMode); $context = array_replace($context, $contextOverrides); } catch (\Throwable) { // Let the render call surface any meaningful parser/loader error. } return $twig->render($this->relativeTemplateName($templatePath), $context); } finally { error_reporting($previousLevel); } } /** * @return list */ public function renderAllTemplates(bool $emailMode = false): array { $findings = []; // Suppress E_NOTICE/E_WARNING only for the duration of this harness. // The render-everything pass routinely coerces a PermissiveStub to a // scalar (e.g. `|format_currency` on a stub amount, or arithmetic on a // stub count), which raises PHP notices that aren't bugs in the // templates themselves. Restoring the previous level on the way out // keeps unrelated tests in the suite under their normal reporting // settings. $previousLevel = error_reporting(); error_reporting($this->suppressNoticesAndWarnings($previousLevel)); try { $twig = $this->buildEnvironment($emailMode); $templates = $emailMode ? $this->discoverEmailTemplates() : $this->discoverAllTemplates(); foreach ($templates as $templatePath) { $relative = $this->relativeTemplateName($templatePath); $context = $this->buildContext($emailMode); // Pre-populate the context with stubs for every variable referenced in // the template. This lets partials (which expect parent-passed // variables) render successfully in isolation. try { $context = $this->enrichContextWithTemplateVariables($twig, $templatePath, $context, $emailMode); } catch (\Throwable) { // If we can't parse the template, fall through to render and let // the loader/parser surface a meaningful error. } try { $twig->render($relative, $context); } catch (\Throwable $e) { $findings[] = [ 'file' => $templatePath, 'template' => $relative, 'error' => $this->summarizeError($e), 'category' => $this->categorizeError($e), ]; } } } finally { error_reporting($previousLevel); } return $findings; } /** * @param array $context * * @return array */ private function enrichContextWithTemplateVariables(Environment $twig, string $templatePath, array $context, bool $emailMode): array { $source = new \Twig\Source((string) file_get_contents($templatePath), $this->relativeTemplateName($templatePath)); try { $stream = $twig->tokenize($source); $nodes = $twig->parse($stream); } catch (\Throwable) { return $context; } $visitor = new VariableCollectorVisitor(); $traverser = new NodeTraverser($twig, [$visitor]); $traverser->traverse($nodes); $stub = new PermissiveStub(); foreach ($visitor->getVariableNames() as $name) { // Don't overwrite globals already provided in the context. if (!array_key_exists($name, $context)) { $context[$name] = $stub; } } return $context; } private function buildEnvironment(bool $emailMode, bool $stringifyUrls = false): Environment { $loader = new CombinedTwigLoader(PATH_THEMES); // Also register every module template directory under the unnamed path // so unqualified `{% include 'mod_foo.html.twig' %}` calls resolve. // CombinedTwigLoader already registers them under `@Module_area` // namespaces; this mirrors that for bare-name lookups. $finder = new Finder(); $finder->directories()->in(PATH_MODS)->depth('== 2')->ignoreDotFiles(true)->name(['admin', 'client', 'email']); foreach ($finder as $dir) { $loader->addPath($dir->getPathName()); } $twig = new Environment($loader, [ 'strict_variables' => true, 'auto_reload' => true, 'cache' => false, 'debug' => false, ]); $twig->getExtension(CoreExtension::class)->setNumberFormat(2, '.', ''); $twig->getExtension(CoreExtension::class)->setTimezone('UTC'); $dateFormatter = new \IntlDateFormatter('en', \IntlDateFormatter::MEDIUM, \IntlDateFormatter::SHORT, 'UTC'); $twig->addExtension(new IntlExtension($dateFormatter)); $twig->addExtension(new MarkdownExtension()); $twig->addExtension(new AttributeExtension(ApiExtension::class)); $twig->addExtension(new AttributeExtension(FOSSBillingExtension::class)); $twig->addExtension(new AttributeExtension(LegacyExtension::class)); // Stub the debug bar functions so admin layout templates that reference // `{{ debug_bar_render_head() }}` and `{{ debug_bar_render() }}` parse // and render in the test environment. $twig->addFunction(new \Twig\TwigFunction('debug_bar_render_head', fn (): string => '')); $twig->addFunction(new \Twig\TwigFunction('debug_bar_render', fn (): string => '')); // Runtime loader: provides stub markdown + FOSSBilling/Legacy/Api // extension instances so filter/function method dispatch works in the // test environment. A PermissiveContainer absorbs every `$di['x']` // access so the extensions can render without a live DI graph. $di = $stringifyUrls ? $this->buildUrlAwareContainer() : new PermissiveContainer(); $twig->addRuntimeLoader(new readonly class($di) implements \Twig\RuntimeLoader\RuntimeLoaderInterface { public function __construct(private \Pimple\Container $di) { } public function load($class) { if ($class === MarkdownRuntime::class) { return new MarkdownRuntime(new class implements MarkdownInterface { public function convert(string $body): string { return $body; } }); } if ($class === FOSSBillingExtension::class) { return new FOSSBillingExtension($this->di); } if ($class === LegacyExtension::class) { return new LegacyExtension($this->di); } if ($class === ApiExtension::class) { return new ApiExtension($this->di); } return null; } }); $context = $this->buildContext($emailMode); foreach ($context as $name => $value) { $twig->addGlobal($name, $value); } return $twig; } private function buildUrlAwareContainer(): \Pimple\Container { return new UrlAwarePermissiveContainer(); } /** * @return array */ private function buildContext(bool $emailMode): array { $stub = new PermissiveStub(); if ($emailMode) { // Email templates have a different `guest` shape with precomputed globals. return [ 'request' => $stub, 'request_query' => $stub, 'request_path' => '/', 'request_has_filters' => false, 'CSRFToken' => 'test', 'FOSSBillingVersion' => '0.0.0', 'default_currency' => 'USD', 'app_area' => 'email', 'current_theme' => 'default/admin', 'theme' => ['code' => 'default/admin', 'name' => 'default/admin', 'url' => '/themes/default/admin/'], 'settings' => $stub, 'guest' => [ 'system_company' => [ 'name' => 'Test Co', 'signature' => '', 'favicon_url' => '', ], 'system_email' => [ 'signature' => '', ], ], ]; } return [ 'request' => $stub, 'request_query' => $stub, 'request_path' => '/', 'request_has_filters' => false, 'CSRFToken' => 'test', 'FOSSBillingVersion' => '0.0.0', 'default_currency' => 'USD', 'app_area' => 'admin', 'current_theme' => 'default/admin', 'theme' => ['code' => 'default/admin', 'name' => 'default/admin', 'url' => '/themes/default/admin/'], 'settings' => $stub, 'admin' => $stub, 'client' => $stub, 'guest' => $stub, 'mf' => $stub, // Most templates import this; let missing macros surface as errors ]; } /** * @return list */ private function discoverAllTemplates(): array { $paths = []; // Module templates $finder = new Finder(); $finder->files()->in(PATH_MODS)->name('*.html.twig'); foreach ($finder as $file) { $paths[] = $file->getPathName(); } // Theme templates $finder = new Finder(); $finder->files()->in(PATH_THEMES)->name('*.html.twig'); foreach ($finder as $file) { $paths[] = $file->getPathName(); } sort($paths); return $paths; } /** * @return list */ private function discoverEmailTemplates(): array { $paths = []; $finder = new Finder(); $finder->files()->in(PATH_MODS)->path('templates/email')->name('*.html.twig'); foreach ($finder as $file) { $paths[] = $file->getPathName(); } sort($paths); return $paths; } private function relativeTemplateName(string $absolutePath): string { // Module templates live under `modules//templates//` and // are referenced as `@_/` (underscore join // because FilesystemLoader splits `@ns/template` on the first '/' and // rejects '/' in namespace names). Theme templates live under // `themes//html/` and are referenced by basename. if (str_starts_with($absolutePath, PATH_MODS . DIRECTORY_SEPARATOR)) { $relative = substr($absolutePath, strlen(PATH_MODS . DIRECTORY_SEPARATOR)); $parts = explode(DIRECTORY_SEPARATOR, $relative, 4); if (count($parts) === 4) { $module = $parts[0]; $area = $parts[2]; // skip 'templates' dir return '@' . $module . '_' . $area . '/' . basename($parts[3]); } } return basename($absolutePath); } private const int MAX_ERROR_MESSAGE_LENGTH = 250; private function summarizeError(\Throwable $e): string { $message = $e->getMessage(); if (strlen($message) > self::MAX_ERROR_MESSAGE_LENGTH) { $message = substr($message, 0, self::MAX_ERROR_MESSAGE_LENGTH) . '...'; } return $e::class . ': ' . $message; } /** * Classify a render error as either a real template bug or a test * infrastructure issue. The strict-variables test fails only on * `real-bug` findings; `test-infra` and `loader` issues are informational * because the permissive container/stub setup cannot satisfy strict * return-type hints or lookup every real template path. */ private function categorizeError(\Throwable $e): string { $message = $e->getMessage(); if ($e instanceof \Twig\Error\LoaderError) { // LoaderErrors during render are usually caused by child templates // (e.g. `partial_embed_styles.html.twig`) being looked up in the // current namespace rather than the main one. This is a quirk of // the render-everything harness, not a real template bug. return 'test-infra'; } if ($e instanceof \Twig\Error\SyntaxError) { return 'real-bug'; } if (str_contains($message, 'Variable ') && str_contains($message, ' does not exist')) { return 'real-bug'; } if (str_contains($message, 'Key "') && str_contains($message, ' does not exist')) { return 'real-bug'; } // Test infrastructure issues: type mismatches caused by PermissiveStub // being returned where a typed return is expected (string, array, etc.), // and method-on-null errors caused by stub methods that don't match real // service signatures. if (str_contains($message, 'Return value must be of type')) { return 'test-infra'; } if (str_contains($message, 'on null')) { return 'test-infra'; } if (str_contains($message, 'Unable to load')) { return 'test-infra'; } if (str_contains($message, 'must be of type float') || str_contains($message, 'must be of type int')) { return 'test-infra'; } if (str_contains($message, 'Unsupported operand types') && str_contains($message, PermissiveStub::class)) { // Templates that do arithmetic directly on two attribute lookups // (e.g. `order.total - order.discount`) hit this when both sides // resolve to PermissiveStub instead of a real number. PHP objects // don't support arithmetic operators, so this is a stub // limitation, not a template bug. The class-name check keeps a // genuine "Unsupported operand types" bug elsewhere in a // template (unrelated to the stub) classified as real-bug. return 'test-infra'; } if (str_contains($message, 'NumberFormatter::formatCurrency')) { return 'test-infra'; } if (str_contains($message, 'is not callable')) { return 'test-infra'; } if (str_contains($message, 'is not iterable')) { return 'test-infra'; } return 'real-bug'; } }