+ +
Hello, Foundation
', + $view->render( + 'message', + [ 'message' => 'Hello, Foundation' ] + ) +); +``` + +Test feature services through the `View` contract when the rendered markup is part of their observable behavior. Use a temporary directory under `tests/_data/temp` for path-containment or runtime-directory tests that must create files. diff --git a/src/Docs/src/content/docs/start/what-is-foundation.md b/src/Docs/src/content/docs/start/what-is-foundation.md index a93c050..3179821 100644 --- a/src/Docs/src/content/docs/start/what-is-foundation.md +++ b/src/Docs/src/content/docs/start/what-is-foundation.md @@ -5,7 +5,7 @@ sidebar: order: 1 --- -Foundation is a Composer monorepo of reusable PHP components maintained for libraries and WordPress plugin ecosystems. It provides common application infrastructure without requiring every project to invent its own container, logging, locking, database, identifier, pipeline, shutdown, or command conventions. +Foundation is a Composer monorepo of reusable PHP components maintained for libraries and WordPress plugin ecosystems. It provides common application infrastructure without requiring every project to invent its own container, logging, locking, database, identifier, pipeline, shutdown, view, or command conventions. Foundation is primarily developed for internal Nexcess projects. Its packages are publicly available and designed to remain reusable, but the needs of Nexcess applications will primarily drive changes, priorities, and the project roadmap. diff --git a/src/View/.gitattributes b/src/View/.gitattributes new file mode 100644 index 0000000..e82014a --- /dev/null +++ b/src/View/.gitattributes @@ -0,0 +1,7 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore paths when git creates an archive of this package +.gitattributes export-ignore +.gitignore export-ignore +.github export-ignore diff --git a/src/View/.github/workflows/close-pull-request.yml b/src/View/.github/workflows/close-pull-request.yml new file mode 100644 index 0000000..6bfbabe --- /dev/null +++ b/src/View/.github/workflows/close-pull-request.yml @@ -0,0 +1,13 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: "This is a read-only repository. Please submit your PR on the https://github.com/stellarwp/foundation repository.Hello, Foundation
' . PHP_EOL, + $view->render('greeting', [ + 'greeting' => 'Hello', + 'name' => 'Foundation', + ]) + ); + } + + public function test_it_escapes_template_data_in_the_template(): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->assertSame( + '<strong>Hello</strong>, Foundation
' . PHP_EOL, + $view->render('greeting', [ + 'greeting' => 'Hello', + 'name' => 'Foundation', + ]) + ); + } + + public function test_it_renders_a_view_from_a_nested_directory(): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->assertSame('Product summary
' . PHP_EOL, $view->render('admin/product-summary')); + } + + public function test_it_returns_a_new_renderer_for_a_runtime_directory_without_mutating_the_original(): void { + $view = new PhpView($this->data_dir('View/default')); + $runtimeView = $view->withDirectory($this->data_dir('View/runtime')); + + $this->assertNotSame($view, $runtimeView); + $this->assertSame('Runtime directory
' . PHP_EOL, $runtimeView->render('greeting')); + $this->assertSame( + 'Hello, Foundation
' . PHP_EOL, + $view->render('greeting', ['greeting' => 'Hello', 'name' => 'Foundation']) + ); + } + + public function test_view_data_cannot_replace_the_resolved_view_path(): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->assertSame( + 'internal-variable.php', + $view->render('internal-variable', [ + 'foundationViewPath' => $this->data_dir('View/outside.php'), + ]) + ); + } + + public function test_it_restores_the_output_buffer_when_a_view_throws(): void { + $view = new PhpView($this->data_dir('View/default')); + $bufferLevel = ob_get_level(); + + try { + $view->render('throws'); + $this->fail('Expected the view exception to be propagated.'); + } catch (RuntimeException $exception) { + $this->assertSame('View rendering failed.', $exception->getMessage()); + } + + $this->assertSame($bufferLevel, ob_get_level()); + } + + public function test_it_rejects_and_cleans_up_an_unclosed_view_buffer(): void { + $view = new PhpView($this->data_dir('View/default')); + $bufferLevel = ob_get_level(); + + try { + $view->render('unclosed-buffer'); + $this->fail('Expected unbalanced output buffering to be rejected.'); + } catch (RuntimeException $exception) { + $this->assertSame('The view "unclosed-buffer" must leave output buffering unchanged.', $exception->getMessage()); + } + + $this->assertSame($bufferLevel, ob_get_level()); + } + + public function test_it_allows_balanced_buffers_owned_by_the_view(): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->assertSame('Balanced view output.', $view->render('balanced-buffer')); + } + + public function test_it_does_not_close_a_caller_buffer_when_the_view_closes_its_rendering_buffer(): void { + $view = new PhpView($this->data_dir('View/default')); + $bufferLevel = ob_get_level(); + + ob_start(); + + try { + $view->render('closes-buffer'); + $this->fail('Expected an unexpectedly closed rendering buffer to be rejected.'); + } catch (RuntimeException $exception) { + $this->assertSame('The view "closes-buffer" must leave output buffering unchanged.', $exception->getMessage()); + $this->assertSame($bufferLevel + 1, ob_get_level()); + } finally { + while (ob_get_level() > $bufferLevel) { + ob_end_clean(); + } + } + + $this->assertSame($bufferLevel, ob_get_level()); + } + + public function test_it_rejects_a_same_depth_replacement_for_its_rendering_buffer(): void { + $view = new PhpView($this->data_dir('View/default')); + $bufferLevel = ob_get_level(); + + try { + $view->render('replaces-buffer'); + $this->fail('Expected a replaced rendering buffer to be rejected.'); + } catch (RuntimeException $exception) { + $this->assertSame('The view "replaces-buffer" must leave output buffering unchanged.', $exception->getMessage()); + } + + $this->assertSame($bufferLevel, ob_get_level()); + } + + public function test_it_rejects_flushing_its_rendering_buffer_without_leaking_output(): void { + $view = new PhpView($this->data_dir('View/default')); + $bufferLevel = ob_get_level(); + + ob_start(); + + try { + $view->render('flushes-buffer'); + $this->fail('Expected a flushed rendering buffer to be rejected.'); + } catch (RuntimeException $exception) { + $this->assertSame('The view "flushes-buffer" must leave output buffering unchanged.', $exception->getMessage()); + $this->assertSame('', ob_get_contents()); + } finally { + while (ob_get_level() > $bufferLevel) { + ob_end_clean(); + } + } + + $this->assertSame($bufferLevel, ob_get_level()); + } + + public function test_it_rejects_an_invalid_view_directory(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must exist and be readable'); + + new PhpView($this->data_dir('View/missing')); + } + + public function test_it_reports_a_missing_view(): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->expectException(ViewNotFoundException::class); + $this->expectExceptionMessage('The view "missing" could not be found'); + + $view->render('missing'); + } + + /** + * @dataProvider invalid_view_names + */ + #[\PHPUnit\Framework\Attributes\DataProvider('invalid_view_names')] + public function test_it_rejects_unsafe_view_names(string $name): void { + $view = new PhpView($this->data_dir('View/default')); + + $this->expectException(InvalidArgumentException::class); + + $view->render($name); + } + + /** + * @return arrayRuntime directory
' . PHP_EOL, $runtimeView->render('greeting')); + } +} diff --git a/tests/Unit/View/ViewProviderTest.php b/tests/Unit/View/ViewProviderTest.php new file mode 100644 index 0000000..1018927 --- /dev/null +++ b/tests/Unit/View/ViewProviderTest.php @@ -0,0 +1,37 @@ +container->get(Dot::class)->set('view.directory', $this->data_dir('View/default')); + $this->container->register(ViewProvider::class); + + $view = $this->container->get(View::class); + + $this->assertInstanceOf(PhpView::class, $view); + $this->assertSame($view, $this->container->get(View::class)); + $this->assertSame($view, $this->container->get(DirectoryAwareView::class)); + $this->assertSame($view, $this->container->get(PhpView::class)); + $this->assertSame( + 'Hello, Foundation
' . PHP_EOL, + $view->render('greeting', ['greeting' => 'Hello', 'name' => 'Foundation']) + ); + } + + public function test_it_rejects_missing_view_configuration(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('view.directory configuration value must be a non-empty string'); + + $this->container->register(ViewProvider::class); + } +} diff --git a/tests/_data/View/default/admin/product-summary.php b/tests/_data/View/default/admin/product-summary.php new file mode 100644 index 0000000..ce78839 --- /dev/null +++ b/tests/_data/View/default/admin/product-summary.php @@ -0,0 +1 @@ +Product summary
diff --git a/tests/_data/View/default/balanced-buffer.php b/tests/_data/View/default/balanced-buffer.php new file mode 100644 index 0000000..1f2e065 --- /dev/null +++ b/tests/_data/View/default/balanced-buffer.php @@ -0,0 +1,7 @@ += htmlspecialchars($greeting, ENT_QUOTES, 'UTF-8') ?>, = htmlspecialchars($name, ENT_QUOTES, 'UTF-8') ?>
diff --git a/tests/_data/View/default/internal-variable.php b/tests/_data/View/default/internal-variable.php new file mode 100644 index 0000000..43e0588 --- /dev/null +++ b/tests/_data/View/default/internal-variable.php @@ -0,0 +1,3 @@ +Runtime directory