From 33f478466316504a58df22758cf074ee504f665b Mon Sep 17 00:00:00 2001 From: indy koning Date: Thu, 23 May 2024 14:15:35 +0200 Subject: [PATCH] Initial commit --- .editorconfig | 12 ++ .gitattributes | 13 ++ .gitignore | 6 + CONTRIBUTING.md | 31 +++ LICENSE.md | 21 ++ README.md | 58 +++++ composer.json | 61 ++++++ phpstan.neon | 10 + phpunit.xml | 21 ++ src/Middleware/AddHttp3EarlyHints.php | 233 +++++++++++++++++++++ src/ServiceProvider.php | 22 ++ src/config.php | 34 +++ tests/AddHttp3EarlyHintsTest.php | 218 +++++++++++++++++++ tests/TestCase.php | 26 +++ tests/fixtures/pageWithCss.html | 11 + tests/fixtures/pageWithCssAndJs.html | 12 ++ tests/fixtures/pageWithExternalAssets.html | 13 ++ tests/fixtures/pageWithFavicon.html | 12 ++ tests/fixtures/pageWithFetchPreload.html | 13 ++ tests/fixtures/pageWithImages.html | 23 ++ tests/fixtures/pageWithJs.html | 11 + tests/fixtures/pageWithJsInline.html | 13 ++ tests/fixtures/pageWithSVGObject.html | 11 + tests/fixtures/pageWithoutAssets.html | 11 + 24 files changed, 896 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100755 CONTRIBUTING.md create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpstan.neon create mode 100644 phpunit.xml create mode 100644 src/Middleware/AddHttp3EarlyHints.php create mode 100644 src/ServiceProvider.php create mode 100644 src/config.php create mode 100644 tests/AddHttp3EarlyHintsTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/fixtures/pageWithCss.html create mode 100644 tests/fixtures/pageWithCssAndJs.html create mode 100644 tests/fixtures/pageWithExternalAssets.html create mode 100644 tests/fixtures/pageWithFavicon.html create mode 100644 tests/fixtures/pageWithFetchPreload.html create mode 100644 tests/fixtures/pageWithImages.html create mode 100644 tests/fixtures/pageWithJs.html create mode 100644 tests/fixtures/pageWithJsInline.html create mode 100644 tests/fixtures/pageWithSVGObject.html create mode 100644 tests/fixtures/pageWithoutAssets.html diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e5debfb --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +; This file is for unifying the coding style for different editors and IDEs. +; More information at http://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8b5d33c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# Path-based git attributes +# https://www.kernel.org/pub/software/scm/git/docs/gitattributes.html + +# Ignore all test and documentation with "export-ignore". +/.gitattributes export-ignore +/.gitignore export-ignore +/.travis.yml export-ignore +/phpunit.xml.dist export-ignore +/.scrutinizer.yml export-ignore +/tests export-ignore +/CONTRIBUTING.md export-ignore +/README.md export-ignore +/server-push.png export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..652b2af --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +build +composer.lock +docs +vendor +.phpunit.result.cache +.phpunit.cache diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100755 index 0000000..b02c521 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,31 @@ +# Contributing + +Contributions are **welcome** and will be fully **credited**. + +We accept contributions via Pull Requests on [Github](https://github.com/justbetter/laravel-http3earlyhints). + + +## Pull Requests + +- **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](http://pear.php.net/package/PHP_CodeSniffer). + +- **Add tests!** - Your patch won't be accepted if it doesn't have tests. + +- **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. + +- **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. + +- **Create feature branches** - Don't ask us to pull from your master branch. + +- **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. + +- **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. + + +## Running Tests + +``` bash +$ phpunit +``` + +**Happy coding**! diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..d9960be --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +# The MIT License (MIT) + +Copyright (c) 2023 JustBetter + +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in +> all copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +> THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3144b2a --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Early Hints Middleware for Laravel + +Early Hints is a HTTP/3 concept which allows the server to send preconnect and preload headers while it's still preparing a response. +This allows the broser to start loading these resources before the server has finished building and sending a response +[See](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/103). + +This package aims to provide the _easiest_ experience for adding Early Hints to your responses. +Simply route your requests through the `AddHttp3EarlyHints` middleware and it will automatically create and attach the `Link` headers necessary to implement Early Hints for your CSS, JS and Image assets. + +## Installation + +You can install the package via composer: +``` bash +$ composer require justbetter/laravel-http3earlyhints +``` + +Next you must add the `\JustBetter\Http3EarlyHints\Middleware\AddHttp3EarlyHints`-middleware to the kernel. Adding it to the web group is recommeneded as API's do not have assets to push. +```php +// app/Http/Kernel.php + +... +protected $middlewareGroups = [ + 'web' => [ + ... + \JustBetter\Http3EarlyHints\Middleware\AddHttp3EarlyHints::class, + ... + ], + ... +]; +``` + +## Publish config + +```php +php artisan vendor:publish --provider="JustBetter\Http3EarlyHints\ServiceProvider" +``` + + +## Usage + +When you route a request through the `AddHttp3EarlyHints` middleware, the response is scanned for any `link`, `script` or `img` tags that could benefit from being loaded using Early Hints. +These assets will be added to the `Link` header before sending the response to the client. Easy! + +**Note:** To push an image asset, it must have one of the following extensions: `bmp`, `gif`, `jpg`, `jpeg`, `png`, `tiff` or `svg` and not have loading="lazy" + +## Testing + +``` bash +$ composer test +``` + +## Contributing + +Please see [CONTRIBUTING](CONTRIBUTING.md) for details. + +## License + +The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..97e7c1a --- /dev/null +++ b/composer.json @@ -0,0 +1,61 @@ +{ + "name": "justbetter/laravel-http3earlyhints", + "description": "A HTTP3 Early Hints Middleware for Laravel", + "keywords": [ + "laravel", + "laravel-http3earlyhints", + "serverpush", + "http/3", + "early hints" + ], + "homepage": "https://github.com/justbetter/laravel-http3earlyhints", + "license": "MIT", + "authors": [ + { + "name": "Indy Koning", + "email": "indy@justbetter.nl", + "role": "Developer" + } + ], + "require": { + "php" : "^8.0", + "laravel/framework": "^10.0|^11.0", + "symfony/dom-crawler": "^6.0|^7.0", + "symfony/css-selector": "^6.0|^7.0" + }, + "require-dev": { + "laravel/pint": "^1.7", + "larastan/larastan": "^2.5", + "phpstan/phpstan-mockery": "^1.1", + "phpunit/phpunit": "^10.1", + "orchestra/testbench": "^8.0|^9.0" + }, + "autoload": { + "psr-4": { + "JustBetter\\Http3EarlyHints\\": "src" + } + }, + "autoload-dev": { + "psr-4": { + "JustBetter\\Http3EarlyHints\\Tests\\": "tests" + } + }, + "scripts": { + "test": "phpunit", + "analyse": "phpstan", + "style": "pint --test", + "quality": [ + "@test", + "@analyse", + "@style" + ], + "fix-style": "pint" + }, + "extra": { + "laravel": { + "providers": [ + "JustBetter\\Http3EarlyHints\\ServiceProvider" + ] + } + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..11f774b --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,10 @@ +includes: + - ./vendor/larastan/larastan/extension.neon + - ./vendor/phpstan/phpstan-mockery/extension.neon + +parameters: + paths: + - src + - tests + level: 4 + checkMissingIterableValueType: false diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..ca2ccf2 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,21 @@ + + + + + tests + + + + + src/ + + + + + + + + + + + diff --git a/src/Middleware/AddHttp3EarlyHints.php b/src/Middleware/AddHttp3EarlyHints.php new file mode 100644 index 0000000..c89660c --- /dev/null +++ b/src/Middleware/AddHttp3EarlyHints.php @@ -0,0 +1,233 @@ +path(), '/'); + if ( + $request->isJson() + || (str_contains($lastPath, '.') && ! in_array(Str::afterLast($lastPath, '.'), $this->getConfig('extensions', ['', 'php', 'html']))) + ) { + $this->skipCurrentRequest = true; + + return $next($request); + } + + $this->limit = $limit; + $this->sizeLimit = $sizeLimit; + $this->excludeKeywords = $excludeKeywords; + + $linkHeaders = Cache::store($this->getConfig('cache_driver'))->get('earlyhints-'.md5($request->url())); + if (! $linkHeaders) { + $response = $next($request); + $linkHeaders = $this->handleGeneratingLinkHeaders($request, $response); + if ($linkHeaders) { + $this->addLinkHeader($response, $linkHeaders); + } + + return $response; + } + + if ($this->getConfig('set_103')) { + $response = new Response(); + $this->addLinkHeader($response, $linkHeaders); + $response->sendHeaders(103); + + $realResponse = $next($request); + $this->addLinkHeader($realResponse, $linkHeaders); + + $reflectionResponse = new ReflectionClass(SymfonyResponse::class); + $reflectionSentHeaders = $reflectionResponse->getProperty('sentHeaders'); + if ($reflectionSentHeaders->isInitialized($response)) { + $reflectionSentHeaders->setValue( + $realResponse, + $reflectionSentHeaders->getValue($response) + ); + } + + return $realResponse; + } + + $response = $next($request); + $this->addLinkHeader($response, $linkHeaders); + + return $response; + } + + /** + * We only start crawling once the response has already been sent to the client in order to reduce impact on performance. + */ + public function terminate(Request $request, Response $response): void + { + $this->handleGeneratingLinkHeaders($request, $response); + } + + public function handleGeneratingLinkHeaders(Request $request, Response $response) + { + if ( + $response->isRedirection() + || ! $response->isSuccessful() + || $this->skipCurrentRequest + ) { + return; + } + $linkHeaders = $this->generateLinkHeaders($response, $this->limit, $this->sizeLimit, $this->excludeKeywords); + + Cache::store($this->getConfig('cache_driver'))->put( + 'earlyhints-'.md5($request->url()), + $linkHeaders, + $this->getConfig('cache_duration', 864000) + ); + + return $linkHeaders; + } + + public function getConfig(mixed $key, mixed $default = false): mixed + { + if (! function_exists('config')) { // for tests.. + return $default; + } + + return config('http3earlyhints.'.$key, $default); + } + + protected function generateLinkHeaders(Response $response, ?int $limit = null, ?int $sizeLimit = null, ?array $excludeKeywords = null): Collection + { + $excludeKeywords = array_filter($excludeKeywords ?? $this->getConfig('exclude_keywords', [])); + $headers = $this->fetchLinkableNodes($response) + ->flatMap(function ($element) { + [$src, $href, $data, $rel, $type] = $element; + $rel = $type === 'module' ? 'modulepreload' : $rel; + + return [ + $this->buildLinkHeaderString($src ?? '', $rel ?? null), + $this->buildLinkHeaderString($href ?? '', $rel ?? null), + $this->buildLinkHeaderString($data ?? '', $rel ?? null), + ]; + }) + ->merge($this->getConfig('default_headers', [])) + ->unique() + ->filter(function ($value, $key) use ($excludeKeywords) { + if (! $value) { + return false; + } + $exclude_keywords = collect($excludeKeywords)->map(function ($keyword) { + return preg_quote($keyword); + }); + if ($exclude_keywords->count() <= 0) { + return true; + } + + return ! preg_match('%('.$exclude_keywords->implode('|').')%i', $value); + }) + ->take($limit); + + $sizeLimit = $sizeLimit ?? max(1, intval($this->getConfig('size_limit', 32 * 1024))); + $headersText = trim($headers->implode(',')); + while (strlen($headersText) > $sizeLimit) { + $headers->pop(); + $headersText = trim($headers->implode(',')); + } + + return $headers; + } + + /** + * Get the DomCrawler instance. + */ + protected function getCrawler(Response $response): Crawler + { + return $this->crawler ??= new Crawler($response->getContent()); + } + + /** + * Get all nodes we are interested in pushing. + */ + protected function fetchLinkableNodes(Response $response): Collection + { + $crawler = $this->getCrawler($response); + + return collect($crawler->filter('link:not([rel*="icon"]):not([rel="canonical"]):not([rel="manifest"]):not([rel="alternate"]), script[src], *:not(picture)>img[src]:not([loading="lazy"]), object[data]')->extract(['src', 'href', 'data', 'rel', 'type'])); + } + + /** + * Build out header string based on asset extension. + */ + private function buildLinkHeaderString(string $url, ?string $rel = 'preload'): ?string + { + $linkTypeMap = [ + '.CSS' => 'style', + '.JS' => 'script', + '.BMP' => 'image', + '.GIF' => 'image', + '.JPG' => 'image', + '.JPEG' => 'image', + '.PNG' => 'image', + '.SVG' => 'image', + '.TIFF' => 'image', + '.WEBP' => 'image', + '.WOFF' => 'font', + '.WOFF2' => 'font', + ]; + + $type = collect($linkTypeMap)->first(function ($type, $extension) use ($url) { + return Str::contains(strtoupper($url), $extension); + }); + + if ($url && ! $type) { + $type = 'fetch'; + } + + if (! preg_match('%^(https?:)?//%i', $url)) { + $basePath = $this->getConfig('base_path', '/'); + $url = $basePath.ltrim($url, $basePath); + } + + if (! in_array($rel, ['preload', 'modulepreload'])) { + $rel = 'preload'; + } + + return is_null($type) ? null : "<{$url}>; rel={$rel}; as={$type}".($type == 'font' ? '; crossorigin' : ''); + } + + /** + * Add Link Header + */ + private function addLinkHeader(Response $response, mixed $link): Response + { + $link = trim(collect($link)->implode(',')); + if (! $link) { + return $response; + } + if ($response->headers->get('Link')) { + $link = $response->headers->get('Link').','.$link; + } + + $response->header('Link', $link); + + return $response; + } +} diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php new file mode 100644 index 0000000..f43b13c --- /dev/null +++ b/src/ServiceProvider.php @@ -0,0 +1,22 @@ +mergeConfigFrom(__DIR__.'/config.php', 'http3earlyhints'); + + $this->publishes([ + __DIR__.'/config.php' => config_path('http3earlyhints.php'), + ], 'config'); + } +} diff --git a/src/config.php b/src/config.php new file mode 100644 index 0000000..08c537a --- /dev/null +++ b/src/config.php @@ -0,0 +1,34 @@ + env('EARLY_HINTS_CACHE_DURATION', 864000 /* 24h */), + 'cache_driver' => env('EARLY_HINTS_CACHE_DRIVER', null), + /** Wether to send the 103. This means we do not need an external party to handle early hints but is currently only supported by FrankenPHP */ + 'send_103' => env('EARLY_HINTS_SEND_103', false) && \function_exists('headers_send'), + /** Size limit in bytes */ + 'size_limit' => env('EARLY_HINTS_SIZE_LIMIT', '6000'), + 'base_path' => env('EARLY_HINTS_BASE_PATH', '/'), + /** List of file extensions to return and generate early hints for */ + 'extensions' => array_merge( + explode(',', env('EARLY_HINTS_EXTENSIONS', '')), + [ + 'php', + 'html', + '', + ] + ), + 'exclude_keywords' => array_merge( + explode(',', env('EARLY_HINTS_EXCLUDE_KEYWORDS', '')), + [] + ), + 'default_headers' => array_merge( + explode(',', env('EARLY_HINTS_DEFAULT_HEADERS', '')), + [ + // '; rel=preload; as=style', + ] + ), +]; diff --git a/tests/AddHttp3EarlyHintsTest.php b/tests/AddHttp3EarlyHintsTest.php new file mode 100644 index 0000000..ce0946e --- /dev/null +++ b/tests/AddHttp3EarlyHintsTest.php @@ -0,0 +1,218 @@ +middleware = new AddHttp3EarlyHints(); + parent::setUp(); + } + + public function getNewRequest() + { + return new Request(server: $_SERVER); + } + + /** @test */ + public function it_will_not_exceed_size_limit() + { + $request = $this->getNewRequest(); + + $limit = 75; + $response = $this->middleware->handle($request, $this->getNext('pageWithCssAndJs'), null, $limit, []); + + $this->assertTrue($this->isServerPushResponse($response)); + $this->assertTrue(strlen($response->headers->get('link')) <= $limit); + $this->assertCount(1, explode(',', $response->headers->get('link'))); + } + + /** @test */ + public function it_will_not_add_excluded_asset() + { + $request = $this->getNewRequest(); + + $response = $this->middleware->handle($request, $this->getNext('pageWithCssAndJs'), null, null, ['thing']); + + $this->assertTrue($this->isServerPushResponse($response)); + $this->assertTrue(! Str::contains($response->headers, 'thing')); + $this->assertCount(1, explode(',', $response->headers->get('link'))); + } + + /** @test */ + public function it_will_not_modify_a_response_with_no_server_push_assets() + { + $request = $this->getNewRequest(); + + $response = $this->middleware->handle($request, $this->getNext('pageWithoutAssets')); + + $this->assertFalse($this->isServerPushResponse($response)); + } + + /** @test */ + public function it_will_return_a_css_link_header_for_css() + { + $request = $this->getNewRequest(); + + $response = $this->middleware->handle($request, $this->getNext('pageWithCss')); + + $this->assertTrue($this->isServerPushResponse($response)); + $this->assertStringEndsWith('as=style', $response->headers->get('link')); + } + + /** @test */ + public function it_will_return_a_js_link_header_for_js() + { + $request = $this->getNewRequest(); + + $response = $this->middleware->handle($request, $this->getNext('pageWithJs')); + + $this->assertTrue($this->isServerPushResponse($response)); + $this->assertStringEndsWith('as=script', $response->headers->get('link')); + } + + /** @test */ + public function it_will_return_an_image_link_header_for_images() + { + $request = $this->getNewRequest(); + + $response = $this->middleware->handle($request, $this->getNext('pageWithImages')); + + $this->assertTrue($this->isServerPushResponse($response)); + $this->assertStringEndsWith('as=image', $response->headers->get('link')); + $this->assertCount(7, explode(',', $response->headers->get('link'))); + } + + /** @test */ + public function it_will_return_an_image_link_header_for_svg_objects() + { + $request = $this->getNewRequest(); + + $response = $this->middleware->handle($request, $this->getNext('pageWithSVGObject')); + + $this->assertTrue($this->isServerPushResponse($response)); + $this->assertStringEndsWith('as=image', $response->headers->get('link')); + $this->assertCount(1, explode(',', $response->headers->get('link'))); + } + + /** @test */ + public function it_will_return_a_fetch_link_header_for_fetch() + { + $request = $this->getNewRequest(); + + $response = $this->middleware->handle($request, $this->getNext('pageWithFetchPreload')); + + $this->assertTrue($this->isServerPushResponse($response)); + $this->assertStringContainsString('; rel=preload', $response->headers->get('link')); + $this->assertStringEndsWith('as=script', $response->headers->get('link')); + } + + /** @test */ + public function it_returns_well_formatted_link_headers() + { + $request = $this->getNewRequest(); + + $response = $this->middleware->handle($request, $this->getNext('pageWithCss')); + + $this->assertEquals('; rel=preload; as=style', $response->headers->get('link')); + } + + /** @test */ + public function it_will_return_correct_push_headers_for_multiple_assets() + { + $request = $this->getNewRequest(); + + $response = $this->middleware->handle($request, $this->getNext('pageWithCssAndJs')); + + $this->assertTrue($this->isServerPushResponse($response)); + $this->assertTrue(Str::contains($response->headers, 'style')); + $this->assertTrue(Str::contains($response->headers, 'script')); + $this->assertCount(2, explode(',', $response->headers->get('link'))); + } + + /** @test */ + public function it_will_not_return_a_push_header_for_inline_js() + { + $request = $this->getNewRequest(); + + $response = $this->middleware->handle($request, $this->getNext('pageWithJsInline')); + + $this->assertFalse($this->isServerPushResponse($response)); + } + + /** @test */ + public function it_will_not_return_a_push_header_for_icons() + { + $request = $this->getNewRequest(); + + $response = $this->middleware->handle($request, $this->getNext('pageWithFavicon')); + + $this->assertFalse($this->isServerPushResponse($response)); + } + + /** @test */ + public function it_will_return_limit_count_of_links() + { + $request = $this->getNewRequest(); + $limit = 2; + + $response = $this->middleware->handle($request, $this->getNext('pageWithImages'), $limit); + + $this->assertCount($limit, explode(',', $response->headers->get('link'))); + } + + /** @test */ + public function it_will_append_to_header_if_already_present() + { + $request = $this->getNewRequest(); + + $next = $this->getNext('pageWithCss'); + + $response = $this->middleware->handle($request, function ($request) use ($next) { + $response = $next($request); + $response->headers->set('Link', '; rel="alternate"; hreflang="en"'); + + return $response; + }); + + $this->assertTrue($this->isServerPushResponse($response)); + $this->assertStringStartsWith('; rel="alternate"; hreflang="en",', $response->headers->get('link')); + $this->assertStringEndsWith('as=style', $response->headers->get('link')); + } + + /** + * @param string $pageName + * @return \Closure + */ + protected function getNext($pageName) + { + $html = $this->getHtml($pageName); + + $response = (new \Illuminate\Http\Response($html)); + + return function ($request) use ($response) { + return $response; + }; + } + + /** + * @param string $pageName + * @return string + */ + protected function getHtml($pageName) + { + return file_get_contents(__DIR__."/fixtures/{$pageName}.html"); + } + + private function isServerPushResponse($response) + { + return $response->headers->has('Link'); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..c7422aa --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,26 @@ +set('database.default', 'testbench'); + $app['config']->set('database.connections.testbench', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + } +} diff --git a/tests/fixtures/pageWithCss.html b/tests/fixtures/pageWithCss.html new file mode 100644 index 0000000..2498076 --- /dev/null +++ b/tests/fixtures/pageWithCss.html @@ -0,0 +1,11 @@ + + + + Page title + + + + + + + diff --git a/tests/fixtures/pageWithCssAndJs.html b/tests/fixtures/pageWithCssAndJs.html new file mode 100644 index 0000000..b9cf6fe --- /dev/null +++ b/tests/fixtures/pageWithCssAndJs.html @@ -0,0 +1,12 @@ + + + + Page title + + + + + + + + diff --git a/tests/fixtures/pageWithExternalAssets.html b/tests/fixtures/pageWithExternalAssets.html new file mode 100644 index 0000000..04490f8 --- /dev/null +++ b/tests/fixtures/pageWithExternalAssets.html @@ -0,0 +1,13 @@ + + + + Page title + + + + + + + + + diff --git a/tests/fixtures/pageWithFavicon.html b/tests/fixtures/pageWithFavicon.html new file mode 100644 index 0000000..7e75f3f --- /dev/null +++ b/tests/fixtures/pageWithFavicon.html @@ -0,0 +1,12 @@ + + + + Page title + + + + + + + + \ No newline at end of file diff --git a/tests/fixtures/pageWithFetchPreload.html b/tests/fixtures/pageWithFetchPreload.html new file mode 100644 index 0000000..e231c28 --- /dev/null +++ b/tests/fixtures/pageWithFetchPreload.html @@ -0,0 +1,13 @@ + + + + Page title + + + + + + + + + diff --git a/tests/fixtures/pageWithImages.html b/tests/fixtures/pageWithImages.html new file mode 100644 index 0000000..b623df8 --- /dev/null +++ b/tests/fixtures/pageWithImages.html @@ -0,0 +1,23 @@ + + + + Page title + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/pageWithJs.html b/tests/fixtures/pageWithJs.html new file mode 100644 index 0000000..8eb3a7e --- /dev/null +++ b/tests/fixtures/pageWithJs.html @@ -0,0 +1,11 @@ + + + + Page title + + + + + + + diff --git a/tests/fixtures/pageWithJsInline.html b/tests/fixtures/pageWithJsInline.html new file mode 100644 index 0000000..eea483f --- /dev/null +++ b/tests/fixtures/pageWithJsInline.html @@ -0,0 +1,13 @@ + + + + Page title + + + + + + + diff --git a/tests/fixtures/pageWithSVGObject.html b/tests/fixtures/pageWithSVGObject.html new file mode 100644 index 0000000..c4bab5a --- /dev/null +++ b/tests/fixtures/pageWithSVGObject.html @@ -0,0 +1,11 @@ + + + + Page title + + + + + + + diff --git a/tests/fixtures/pageWithoutAssets.html b/tests/fixtures/pageWithoutAssets.html new file mode 100644 index 0000000..3c3e5cb --- /dev/null +++ b/tests/fixtures/pageWithoutAssets.html @@ -0,0 +1,11 @@ + + + + + + + +
Content
+ + +