From a8d46c9a712ab789a9031c2fee99cbd6bbf616e2 Mon Sep 17 00:00:00 2001 From: stromek Date: Mon, 3 Aug 2026 10:22:56 +0200 Subject: [PATCH] Add HTML reporter with payload snapshots and test source locations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HtmlReporter (--output-html=FILE) renders one run as a single self-contained HTML file: summary cards, per-group tallies, expandable tests, failure details, request/response payloads with a cURL reproduction, session chain context and live filters. The canonical JSON is embedded and rendered client-side by the template's JS, so the same template can later back a standalone JSON viewer. JSON schema bumped to v3: - tests[].request / tests[].response — payloads as actually sent/received, produced by Reporting\Support\PayloadFormatter (sensitive headers redacted, bodies truncated at 16 kB, binary bodies reported as size only) - tests[].source — definition file + line of each test - meta.base_path, meta.payloads Request now owns the wire encoding (encodedBody(), effectiveHeaders(), bodyEncoding()) and CurlMultiClient uses those methods, so reports cannot drift from what was sent. Runner threads the sent Request into Result. Definition\SourceLocation captures the file+line of each get()/post()/... call, surfaced in the HTML report (badge, "Defined in" block, file filter), under console failures (at file:line) and in the Markdown failure details. New CLI flags: --output-html=FILE, --no-payloads, --payload-limit=BYTES New config: htmlOutputPath, includePayloads, payloadMaxBytes, redactedHeaders, projectRoot --- .gitignore | 1 + CLAUDE.md | 8 +- README.md | 98 +++- docs/claude/architecture.md | 11 +- docs/claude/roadmap.md | 19 +- docs/claude/workflows.md | 3 + src/Config/SmokeConfig.php | 12 + src/Console/InputParser.php | 9 + src/Console/ParsedInput.php | 6 + src/Console/RunCommand.php | 10 + src/Container/ServiceFactory.php | 20 + src/Definition/GroupBuilder.php | 2 + src/Definition/RequestBuilder.php | 2 + src/Definition/SourceLocation.php | 43 ++ src/Definition/TestCase.php | 1 + src/Execution/Result.php | 6 +- src/Execution/Runner.php | 19 +- src/Http/Curl/CurlMultiClient.php | 45 +- src/Http/Request.php | 57 ++ src/Reporting/ConsoleReporter.php | 6 + src/Reporting/GithubSummaryReporter.php | 2 +- src/Reporting/HtmlReporter.php | 93 +++ src/Reporting/JsonReporter.php | 39 +- src/Reporting/MarkdownReporter.php | 8 +- src/Reporting/Support/PayloadFormatter.php | 139 +++++ src/Reporting/templates/report.html | 550 ++++++++++++++++++ tests/Unit/Definition/SourceLocationTest.php | 48 ++ tests/Unit/Reporting/HtmlReporterTest.php | 185 ++++++ tests/Unit/Reporting/JsonReporterTest.php | 134 +++++ tests/Unit/Reporting/PayloadFormatterTest.php | 154 +++++ 30 files changed, 1649 insertions(+), 81 deletions(-) create mode 100644 src/Definition/SourceLocation.php create mode 100644 src/Reporting/HtmlReporter.php create mode 100644 src/Reporting/Support/PayloadFormatter.php create mode 100644 src/Reporting/templates/report.html create mode 100644 tests/Unit/Definition/SourceLocationTest.php create mode 100644 tests/Unit/Reporting/HtmlReporterTest.php create mode 100644 tests/Unit/Reporting/JsonReporterTest.php create mode 100644 tests/Unit/Reporting/PayloadFormatterTest.php diff --git a/.gitignore b/.gitignore index 75fc91f..cdca75e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ /.vscode/ *.local.php .DS_Store +/build/ diff --git a/CLAUDE.md b/CLAUDE.md index ca02469..d9c90a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,11 +30,11 @@ cases: post-deploy smoke checks, scheduled monitoring, CI gates. - [`docs/claude/roadmap.md`](docs/claude/roadmap.md) — planned work, known limitations. - [`README.md`](README.md) — user-facing docs (install, CLI, DSL cheat-sheet, JSON schema). -## Status (as of 2026-04-26) +## Status (as of 2026-08-03) -- Version 0.1 released: package skeleton + full source + tests + CI + docs + examples + bundled dev-server. -- All checks green: PHPStan max (0 errors), PHP-CS-Fixer (clean), PHPUnit (81 tests, 210 assertions), examples (26/26 against the bundled dev-server). -- Recent additions: `expectHtmlElement()` (DOM-based HTML assertion), `defaultRetries()` alias, `ResolvableAssertion` interface so assertion args can resolve `{KEY}` variables at runtime (`RedirectAssertion` is the first user), and `head()` / `options()` DSL methods (HEAD uses `CURLOPT_NOBODY`, dev server maps HEAD→GET and answers OPTIONS with `Allow`). See `docs/claude/architecture.md` and `roadmap.md`. +- Latest tag: v0.4.0; v0.5.0 prepared (HTML report + payload snapshots + test source locations). +- All checks green: PHPStan max (0 errors), PHP-CS-Fixer (clean), PHPUnit (105 tests, 290 assertions), examples (26/26 against the bundled dev-server). +- Recent additions: `HtmlReporter` (`--output-html=FILE`) — self-contained HTML report with filters, group tallies, failure details, request/response payloads (+ cURL), per-test source `file:line` and session chain context (template: `src/Reporting/templates/report.html`); JSON schema v3 (`request`/`response`/`source` blocks, `meta.base_path`, `meta.payloads`; `--no-payloads`, `--payload-limit=`), `expectHtmlElement()` (DOM-based HTML assertion), `defaultRetries()` alias, `ResolvableAssertion` interface so assertion args can resolve `{KEY}` variables at runtime (`RedirectAssertion` is the first user), and `head()` / `options()` DSL methods (HEAD uses `CURLOPT_NOBODY`, dev server maps HEAD→GET and answers OPTIONS with `Allow`). See `docs/claude/architecture.md` and `roadmap.md`. ## ⚠️ Keep this documentation up to date diff --git a/README.md b/README.md index 6dc34cc..fda28a2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Fast, simple and extensible HTTP smoke-testing toolkit for PHP 8.4+. A fluent DSL for declaring HTTP smoke tests, parallel execution, capture chains between requests, cookie-shared session flows, pluggable variable sources -(`.env`, JSON, OS env, your own), pluggable reporters (console, JSON, Markdown, +(`.env`, JSON, OS env, your own), pluggable reporters (console, JSON, Markdown, HTML, GitHub Actions step summary, your own), and a small DI container so any service can be swapped without forking the package. @@ -28,8 +28,9 @@ vendor/bin/http-smoke dev `{@hash}` in subsequent URLs / bodies / headers. - **Variable substitution** — `{ENV_VAR}` placeholders resolved from `.env`, `smokeHttp.json`, OS env, CLI overrides — or any custom `VariableSourceInterface`. -- **Pluggable reporters** — console, JSON (canonical artefact), Markdown, GitHub - step summary; add your own by implementing `ReporterInterface`. +- **Pluggable reporters** — console, JSON (canonical artefact), Markdown, standalone + HTML (browsable, with filters and chain context), GitHub step summary; add your + own by implementing `ReporterInterface`. - **Pluggable HTTP client** — default is curl_multi; implement `HttpClientInterface` to swap in a mock or alternative backend. - **Retry** — per-test or per-group retry on any failure (useful for @@ -115,6 +116,9 @@ http-smoke [options] --group=NAME Only run group(s); supports wildcards (api.*) --output=FILE Write Markdown report --output-json=FILE Write canonical JSON report + --output-html=FILE Write standalone HTML report + --no-payloads Omit request/response payloads + --payload-limit=BYTES Truncate each payload body (default: 16384) --no-console Suppress console output --no-github-summary Skip GITHUB_STEP_SUMMARY --verbose, -v Show full request/response detail @@ -232,10 +236,11 @@ use Stromcom\HttpSmoke\Container\Container; use Stromcom\HttpSmoke\Variable\Source\ArraySource; return static function (SmokeConfig $config): void { - $config->configDir = __DIR__ . '/tests/SmokeHttp'; - $config->concurrency = 10; - $config->jsonOutputPath = __DIR__ . '/build/smoke.json'; + $config->configDir = __DIR__ . '/tests/SmokeHttp'; + $config->concurrency = 10; + $config->jsonOutputPath = __DIR__ . '/build/smoke.json'; $config->markdownOutputPath = __DIR__ . '/build/smoke.md'; + $config->htmlOutputPath = __DIR__ . '/build/smoke.html'; // Plug in additional variable sources $config->extraVariableSources[] = new ArraySource([ @@ -282,17 +287,19 @@ container via `$config->configureContainer`. ## JSON report schema -The JSON report is the canonical machine-readable artefact. Markdown and GitHub -step-summary outputs are derived from it. +The JSON report is the canonical machine-readable artefact. Markdown, HTML and +GitHub step-summary outputs are derived from it. ```json { "meta": { - "schema_version": 2, + "schema_version": 3, "environment": "prod", "generated_at": "2026-04-26T12:00:00+00:00", "duration_s": 4.521, "concurrency": 10, + "base_path": "/var/www/app", + "payloads": true, "summary": { "total": 15, "passed": 13, "failed": 1, "skipped": 1, "success": false } }, "groups": [ @@ -310,9 +317,33 @@ step-summary outputs are derived from it. "attempts": 1, "total_duration_ms": 234, "session": { "label": "user lifecycle" }, + "source": { + "file": "tests/SmokeHttp/api/users.php", + "absolute_path": "/var/www/app/tests/SmokeHttp/api/users.php", + "line": 24 + }, "failures": ["Expected status 201, got 500"], "skip_reason": null, - "chain_context": [/* preceding session steps */] + "chain_context": [/* preceding session steps */], + "request": { + "method": "POST", + "url": "https://example.com/api/users/", + "query": null, + "headers": { "Authorization": "«redacted»", "Content-Type": "application/json" }, + "body_encoding": "json", + "body": { "text": "{\"name\":\"Ada\"}", "size_bytes": 14, "truncated": false, "binary": false }, + "timeout_s": 10, + "cookie_jar": true, + "insecure_tls": false, + "user_agent": "StromcomSmokeTest/1.0" + }, + "response": { + "status_code": 500, + "headers": { "content-type": "application/json" }, + "content_type": "application/json", + "body": { "text": "{\"error\":\"boom\"}", "size_bytes": 16, "truncated": false, "binary": false }, + "transport_error": null + } } ] } @@ -322,6 +353,53 @@ step-summary outputs are derived from it. --- +## HTML report + +```bash +vendor/bin/http-smoke staging --output-html=build/smoke.html +``` + +One self-contained file — no CDN, no assets, no server. Open it in a browser or +publish it as a CI artefact. It embeds the canonical JSON and renders: + +- summary cards + pass/fail/skip ratio bar, environment / duration / concurrency meta, +- groups with per-group tallies; failed tests are expanded by default, +- per-test detail: method, URL, HTTP code, duration, attempts (total time incl. retries), +- all failure messages verbatim, skip reasons, +- **payloads** — the request exactly as sent (headers incl. the implicit + `Content-Type`, query parameters, body in its wire encoding) and the response + (headers, pretty-printed body), plus a ready-to-run **cURL** reproduction, +- **source** — the definition file and line of each test + (`tests/SmokeHttp/api/users.php:24`), as plain selectable text, +- **chain context** for session tests — every preceding step of the session + (method, status, label, URL, duration) with the failing step highlighted, +- **filters**: full-text search (label / URL / failure message / payload / path), + status (all / failed / passed / skipped), group, definition file, retried-only, + sessions-only, plus expand/collapse all and “show payloads”. + +Dark mode follows the OS setting. The layout logic lives in +`src/Reporting/templates/report.html`; `HtmlReporter` only injects the JSON. + +### Payloads and secrets + +Payloads land in both the JSON and the HTML report, so treat those artefacts as +sensitive. Sensitive headers (`Authorization`, `Cookie`, `Set-Cookie`, +`X-Api-Key`, …) are replaced with `«redacted»`, bodies are truncated at 16 kB and +binary bodies are reported as size only. + +```bash +vendor/bin/http-smoke prod --output-html=report.html --no-payloads +vendor/bin/http-smoke prod --output-html=report.html --payload-limit=65536 +``` + +```php +$config->includePayloads = false; // drop request/response blocks entirely +$config->payloadMaxBytes = 65536; // per-body cap +$config->redactedHeaders = ['authorization', 'x-tenant-secret']; +``` + +--- + ## Examples See [`examples/`](./examples) for ready-to-run test suites and a sample diff --git a/docs/claude/architecture.md b/docs/claude/architecture.md index 695365f..5787b35 100644 --- a/docs/claude/architecture.md +++ b/docs/claude/architecture.md @@ -32,20 +32,20 @@ CLI input → Console → Config → Discovery → Definition │ ▼ Result → Report → Reporters - (Console / Json / Markdown / GitHub) + (Console / Json / Markdown / Html / GitHub) ``` ## Domain folders | Folder | Purpose | |---|---| -| `Definition/` | Fluent DSL: `Suite`, `GroupBuilder`, `RequestBuilder`, immutable `TestCase`, `GroupConfig`. Builds the test plan; nothing here knows how requests are sent. | +| `Definition/` | Fluent DSL: `Suite`, `GroupBuilder`, `RequestBuilder`, immutable `TestCase`, `GroupConfig`, `SourceLocation` (definition file+line of each test, captured via `debug_backtrace` in `GroupBuilder::startRequest()`). Builds the test plan; nothing here knows how requests are sent. | | `Assertion/` | `AssertionInterface` + concrete impls (Status, Json, JsonPath, JsonHasKeys, BodyContains, HeaderContains, Redirect, HtmlElement, Callback). Each `evaluate(Response)` returns `null` (pass) or a failure message. `ResolvableAssertion` is an opt-in sub-interface for assertions whose args contain `{KEY}` placeholders — `CaseTranslator::resolveAssertions()` rebuilds them with the runtime `VariableResolver` before `evaluate()` is called. | | `Capture/` | `CaptureInterface` (JsonPath, Header) + `CaptureStore` (runtime `{@name}` substitution). | | `Variable/` | `VariableResolver` + `VariableSourceInterface` (Array, EnvFile, JsonFile, Getenv). Layered, last-added-wins. Throws `VariableNotFoundException` for unresolved `{KEY}`. | | `Http/` | `HttpClientInterface` + immutable `Request`/`Response` VO + `Curl\CurlMultiClient` (default; parallel via `curl_multi_*`, single via `curl_exec`, cookie jar support). | -| `Execution/` | `Runner` (orchestrator), `Result`, `Report`, `CaseTranslator` (translates `TestCase` → `Request`, applying variables + captures). | -| `Reporting/` | `ReporterInterface` (`onStart`/`onResult`/`onEnd`) + `Console`, `Json`, `Markdown`, `GithubSummary`, `Null`. JSON is canonical; Markdown + GitHub summary derive from it. | +| `Execution/` | `Runner` (orchestrator), `Result` (carries the `TestCase`, the `Response` **and the `Request` actually sent**), `Report`, `CaseTranslator` (translates `TestCase` → `Request`, applying variables + captures). | +| `Reporting/` | `ReporterInterface` (`onStart`/`onResult`/`onEnd`) + `Console`, `Json`, `Markdown`, `Html`, `GithubSummary`, `Null`. JSON is canonical; Markdown, HTML + GitHub summary derive from it. `templates/report.html` is the HTML shell (inline CSS/JS, no external assets). `Support\PayloadFormatter` snapshots request/response payloads (header redaction, body truncation, binary detection). | | `Discovery/` | `ConfigDiscovery` — recursive `*.php` walk + filename filter. Each definition file returns `Closure(Suite): void`. | | `Config/` | `SmokeConfig` (root config DTO) + `SmokeConfigLoader` (loads `smoke.config.php`). | | `Container/` | Lightweight PSR-11 container + `ServiceFactory::build()` wires everything. `getTyped(class)` for type-narrowed retrieval. | @@ -72,6 +72,9 @@ via `smoke.config.php` (`extraVariableSources[]`, `extraReporters[]`, - **Circuit breaker**: each `GroupConfig::$maxFailures` — once exceeded within a group, remaining cases in that group are skipped with reason "Circuit breaker: …". - **HTTP methods**: DSL supports `get` / `post` / `put` / `patch` / `delete` / `head` / `options`. `head()` and `options()` always send no body. In `CurlMultiClient`, `HEAD` is dispatched via `CURLOPT_NOBODY` (otherwise curl waits for a body that never arrives); `OPTIONS` uses `CURLOPT_CUSTOMREQUEST`. Body assertions on `HEAD` will simply see an empty response body — this is intentional, not a bug. - **JSON report schema** is versioned (`JsonReporter::SCHEMA_VERSION`). Markdown + GitHub-summary reporters consume the JSON via `MarkdownReporter::build($data)` — clean separation, easy to derive other formats. +- **Payload snapshots** (schema v3). `Request` owns the wire encoding (`encodedBody()`, `effectiveHeaders()`, `bodyEncoding()`) — `CurlMultiClient` uses the same methods, so what a report shows is exactly what was sent. `Runner` threads the sent `Request` into `Result`; `JsonReporter` emits `request`/`response` blocks via `PayloadFormatter`. Defaults: sensitive headers → `«redacted»`, bodies truncated at 16 kB, non-UTF-8 bodies reported as size only. Toggles: `--no-payloads` / `--payload-limit=BYTES`, `$config->includePayloads`, `$config->payloadMaxBytes`, `$config->redactedHeaders`. Markdown + GitHub-summary reporters build their JSON with `includePayloads: false` (they don't render payloads). +- **Test source location** (schema v3). `SourceLocation::capture()` walks the backtrace to the first frame outside `src/Definition`, so each `TestCase` knows its definition file+line. `meta.base_path` (= project root, set by `RunCommand`) makes the paths relative for display. Surfaced in JSON (`tests[].source`, including `absolute_path` for tooling), HTML (badge, "Defined in" block as plain text — deliberately no editor deep-link, file filter) and as the dim `at file:line` line under console failures + the Markdown failure details. +- **HTML report** (`HtmlReporter`, `--output-html=FILE`) is a single self-contained file: `src/Reporting/templates/report.html` with `__SMOKE_TITLE__` / `__SMOKE_DATA__` placeholders, the canonical JSON embedded in a `` in a label can't break out), and vanilla JS rendering it client-side: summary cards, per-group tallies, expandable test rows (details rendered lazily on first open), failure lists, session/retry/source badges, request+response payload panes with pretty-printed JSON and a cURL reproduction, chain-context timeline, plus live filters (search across labels/URLs/failures/payloads/paths, status, group, definition file, retried-only, sessions-only). Rendering lives in the template's JS — PHP only injects data, so the same template can later back a standalone JSON viewer. ## What NOT to assume diff --git a/docs/claude/roadmap.md b/docs/claude/roadmap.md index 41baf9d..67ba3ec 100644 --- a/docs/claude/roadmap.md +++ b/docs/claude/roadmap.md @@ -2,13 +2,18 @@ ## Planned work -### Next big task: JSON report viewer -The `JsonReporter` produces a canonical, schema-versioned JSON report. The -package needs a dedicated viewer — currently console + markdown are good for -single CI runs, but there's no way to browse historical runs, drill into failed -session chains visually, or compare environments. Open shape: standalone static -HTML+JS, PHP server, or CLI TUI — no commitment yet. (Memory: -`project_future_json_viewer.md`.) +### JSON report viewer — first half shipped +`HtmlReporter` (`--output-html=FILE`) now renders a single run as a +self-contained HTML page: filters, per-group tallies, expandable tests, failure +messages, request/response payloads (+ cURL reproduction), the definition +file:line of each test, and session chain context. It reads the canonical JSON, +so the same template's JS can back a standalone viewer later. + +Still open (the original "viewer" ask, memory `project_future_json_viewer.md`): +- browsing **historical** runs (load N JSON files, trend over time), +- comparing environments side by side, +- a drop-in viewer that takes an arbitrary `report.json` (file picker / + drag & drop) instead of being generated by a run. ## Known limitations diff --git a/docs/claude/workflows.md b/docs/claude/workflows.md index 817671c..645484a 100644 --- a/docs/claude/workflows.md +++ b/docs/claude/workflows.md @@ -55,6 +55,9 @@ Useful options when iterating: --no-github-summary # skip writing $GITHUB_STEP_SUMMARY (useful locally) --output-json=build/report.json # canonical JSON artifact --output=build/report.md # Markdown report +--output-html=build/report.html # standalone HTML report (open in a browser) +--no-payloads # omit request/response payloads from JSON + HTML +--payload-limit=65536 # per-body cap for payloads (default 16384) --var=KEY=VALUE # one-off variable override ``` diff --git a/src/Config/SmokeConfig.php b/src/Config/SmokeConfig.php index 2c2e3f6..0a09549 100644 --- a/src/Config/SmokeConfig.php +++ b/src/Config/SmokeConfig.php @@ -7,6 +7,7 @@ use Closure; use Stromcom\HttpSmoke\Container\Container; use Stromcom\HttpSmoke\Reporting\ReporterInterface; +use Stromcom\HttpSmoke\Reporting\Support\PayloadFormatter; use Stromcom\HttpSmoke\Variable\VariableSourceInterface; final class SmokeConfig @@ -33,6 +34,17 @@ final class SmokeConfig public ?string $markdownOutputPath = null; + public ?string $htmlOutputPath = null; + + public ?string $projectRoot = null; + + public bool $includePayloads = true; + + public int $payloadMaxBytes = PayloadFormatter::DEFAULT_MAX_BYTES; + + /** @var list */ + public array $redactedHeaders = PayloadFormatter::DEFAULT_REDACTED_HEADERS; + public bool $githubSummary = true; public bool $consoleReporter = true; diff --git a/src/Console/InputParser.php b/src/Console/InputParser.php index 9dedacf..28b0c82 100644 --- a/src/Console/InputParser.php +++ b/src/Console/InputParser.php @@ -24,10 +24,16 @@ public function parse(array $argv): ParsedInput $input->noConsole = true; } elseif ($arg === '--no-github-summary') { $input->noGithubSummary = true; + } elseif ($arg === '--no-payloads') { + $input->noPayloads = true; + } elseif (str_starts_with($arg, '--payload-limit=')) { + $input->payloadMaxBytes = (int) substr($arg, 16); } elseif (str_starts_with($arg, '--concurrency=')) { $input->concurrency = (int) substr($arg, 14); } elseif (str_starts_with($arg, '--output-json=')) { $input->jsonOutputPath = substr($arg, 14); + } elseif (str_starts_with($arg, '--output-html=')) { + $input->htmlOutputPath = substr($arg, 14); } elseif (str_starts_with($arg, '--output=')) { $input->markdownOutputPath = substr($arg, 9); } elseif (str_starts_with($arg, '--base-url=')) { @@ -80,6 +86,9 @@ public function helpText(string $binary = 'http-smoke'): string --group=NAME Run only the specified group (supports wildcards: api.*) --output=FILE Write Markdown report to file --output-json=FILE Write canonical JSON report to file + --output-html=FILE Write standalone HTML report to file + --no-payloads Omit request/response payloads from JSON + HTML reports + --payload-limit=BYTES Truncate each payload body (default: 16384) --no-console Suppress console reporter --no-github-summary Skip writing to GITHUB_STEP_SUMMARY --verbose, -v Show full request/response details diff --git a/src/Console/ParsedInput.php b/src/Console/ParsedInput.php index 3c9ac3a..ca45601 100644 --- a/src/Console/ParsedInput.php +++ b/src/Console/ParsedInput.php @@ -18,12 +18,18 @@ final class ParsedInput public bool $noGithubSummary = false; + public bool $noPayloads = false; + + public ?int $payloadMaxBytes = null; + public ?int $concurrency = null; public ?string $jsonOutputPath = null; public ?string $markdownOutputPath = null; + public ?string $htmlOutputPath = null; + public ?string $configDir = null; public ?string $configFile = null; diff --git a/src/Console/RunCommand.php b/src/Console/RunCommand.php index 817ed42..bb585a4 100644 --- a/src/Console/RunCommand.php +++ b/src/Console/RunCommand.php @@ -82,6 +82,7 @@ private function buildConfig(ParsedInput $input, string $projectRoot): SmokeConf { $config = new SmokeConfig(); $config->environment = $input->environment ?? 'dev'; + $config->projectRoot = $projectRoot; $configFile = $input->configFile ?? $projectRoot . '/smoke.config.php'; $this->configLoader->load($config, $configFile); @@ -110,6 +111,9 @@ private function buildConfig(ParsedInput $input, string $projectRoot): SmokeConf if ($input->markdownOutputPath !== null) { $config->markdownOutputPath = $this->resolvePath($input->markdownOutputPath); } + if ($input->htmlOutputPath !== null) { + $config->htmlOutputPath = $this->resolvePath($input->htmlOutputPath); + } if ($input->filter !== null) { $config->filter = $input->filter; } @@ -122,6 +126,12 @@ private function buildConfig(ParsedInput $input, string $projectRoot): SmokeConf if ($input->noGithubSummary) { $config->githubSummary = false; } + if ($input->noPayloads) { + $config->includePayloads = false; + } + if ($input->payloadMaxBytes !== null) { + $config->payloadMaxBytes = max(0, $input->payloadMaxBytes); + } foreach ($input->cliVariables as $key => $value) { $config->cliVariables[$key] = $value; } diff --git a/src/Container/ServiceFactory.php b/src/Container/ServiceFactory.php index b0eee2e..93d7e31 100644 --- a/src/Container/ServiceFactory.php +++ b/src/Container/ServiceFactory.php @@ -14,8 +14,10 @@ use Stromcom\HttpSmoke\Http\HttpClientInterface; use Stromcom\HttpSmoke\Reporting\ConsoleReporter; use Stromcom\HttpSmoke\Reporting\GithubSummaryReporter; +use Stromcom\HttpSmoke\Reporting\HtmlReporter; use Stromcom\HttpSmoke\Reporting\JsonReporter; use Stromcom\HttpSmoke\Reporting\MarkdownReporter; +use Stromcom\HttpSmoke\Reporting\Support\PayloadFormatter; use Stromcom\HttpSmoke\Variable\Source\ArraySource; use Stromcom\HttpSmoke\Variable\Source\EnvFileSource; use Stromcom\HttpSmoke\Variable\Source\GetenvSource; @@ -66,6 +68,11 @@ public static function build(SmokeConfig $config): Container $container->set(ConfigDiscovery::class, static fn(): ConfigDiscovery => new ConfigDiscovery()); + $container->set(PayloadFormatter::class, static fn(): PayloadFormatter => new PayloadFormatter( + $config->payloadMaxBytes, + $config->redactedHeaders, + )); + $container->set(Runner::class, static function (Container $c) use ($config): Runner { $runner = new Runner( $c->getTyped(HttpClientInterface::class), @@ -85,6 +92,9 @@ public static function build(SmokeConfig $config): Container outputPath: $config->jsonOutputPath, environment: $config->environment, concurrency: $config->concurrency, + basePath: $config->projectRoot, + includePayloads: $config->includePayloads, + payloads: $c->getTyped(PayloadFormatter::class), )); } if ($config->markdownOutputPath !== null) { @@ -93,6 +103,16 @@ public static function build(SmokeConfig $config): Container environment: $config->environment, )); } + if ($config->htmlOutputPath !== null) { + $runner->addReporter(new HtmlReporter( + outputPath: $config->htmlOutputPath, + environment: $config->environment, + concurrency: $config->concurrency, + basePath: $config->projectRoot, + includePayloads: $config->includePayloads, + payloads: $c->getTyped(PayloadFormatter::class), + )); + } if ($config->githubSummary) { $runner->addReporter(new GithubSummaryReporter(environment: $config->environment)); } diff --git a/src/Definition/GroupBuilder.php b/src/Definition/GroupBuilder.php index 15e4785..ab6a401 100644 --- a/src/Definition/GroupBuilder.php +++ b/src/Definition/GroupBuilder.php @@ -374,6 +374,7 @@ private function startRequest(Method $method, string $url, array|string|null $bo { $this->commit(); $this->pending = new RequestBuilder(); + $this->pending->source = SourceLocation::capture(); $this->pending->method = $method; $this->pending->url = $this->resolveUrl($url); $this->pending->body = $body; @@ -419,6 +420,7 @@ private function commit(): void sessionId: $this->currentSessionId, assertions: $p->buildAssertions(), captures: $p->captures, + source: $p->source, ); } diff --git a/src/Definition/RequestBuilder.php b/src/Definition/RequestBuilder.php index 58d570a..9164c13 100644 --- a/src/Definition/RequestBuilder.php +++ b/src/Definition/RequestBuilder.php @@ -49,6 +49,8 @@ final class RequestBuilder public bool $skipGroupHeaders = false; + public ?SourceLocation $source = null; + public ?int $expectedStatus = null; /** @var list */ diff --git a/src/Definition/SourceLocation.php b/src/Definition/SourceLocation.php new file mode 100644 index 0000000..97567b6 --- /dev/null +++ b/src/Definition/SourceLocation.php @@ -0,0 +1,43 @@ +file; + } + + $prefix = rtrim(str_replace('\\', '/', $basePath), '/') . '/'; + $file = str_replace('\\', '/', $this->file); + + return str_starts_with($file, $prefix) ? substr($file, strlen($prefix)) : $file; + } + + public function describe(?string $basePath = null): string + { + return "{$this->relativeTo($basePath)}:{$this->line}"; + } +} diff --git a/src/Definition/TestCase.php b/src/Definition/TestCase.php index 07c2ba5..5d2f2b4 100644 --- a/src/Definition/TestCase.php +++ b/src/Definition/TestCase.php @@ -31,6 +31,7 @@ public function __construct( public ?string $sessionId = null, public array $assertions = [], public array $captures = [], + public ?SourceLocation $source = null, ) {} public function describe(): string diff --git a/src/Execution/Result.php b/src/Execution/Result.php index 591887b..81f7e00 100644 --- a/src/Execution/Result.php +++ b/src/Execution/Result.php @@ -5,6 +5,7 @@ namespace Stromcom\HttpSmoke\Execution; use Stromcom\HttpSmoke\Definition\TestCase; +use Stromcom\HttpSmoke\Http\Request; use Stromcom\HttpSmoke\Http\Response; final class Result @@ -25,6 +26,7 @@ private function __construct( public readonly bool $skipped, public readonly ?string $skipReason, array $failures = [], + public readonly ?Request $request = null, ) { $this->failures = $failures; $this->totalDurationSeconds = $response->durationSeconds; @@ -33,9 +35,9 @@ private function __construct( /** * @param list $failures */ - public static function from(TestCase $case, Response $response, array $failures): self + public static function from(TestCase $case, Response $response, array $failures, ?Request $request = null): self { - return new self($case, $response, false, null, $failures); + return new self($case, $response, false, null, $failures, $request); } public static function skipped(TestCase $case, string $reason): self diff --git a/src/Execution/Runner.php b/src/Execution/Runner.php index fafa57c..47bbb8d 100644 --- a/src/Execution/Runner.php +++ b/src/Execution/Runner.php @@ -10,6 +10,7 @@ use Stromcom\HttpSmoke\Definition\TestCase; use Stromcom\HttpSmoke\Exception\VariableNotFoundException; use Stromcom\HttpSmoke\Http\HttpClientInterface; +use Stromcom\HttpSmoke\Http\Request; use Stromcom\HttpSmoke\Http\Response; use Stromcom\HttpSmoke\Reporting\ReporterInterface; @@ -170,7 +171,7 @@ private function runParallel(array $cases): array foreach ($responses as $idx => $response) { $caseIdx = $caseIndex[$idx]; $case = $cases[$caseIdx]; - $result = $this->buildResult($case, $response); + $result = $this->buildResult($case, $response, $requests[$idx]); $results[$caseIdx] = $this->retryNonSession($case, $result); } @@ -231,13 +232,13 @@ private function runSessionChain(array $cases): array } $response = $this->client->send($request); - $result = $this->buildResult($case, $response); + $result = $this->buildResult($case, $response, $request); while ($result->isFailed() && $case->retryOnFailure > 0 && $attempts <= $case->retryOnFailure) { usleep(max(1, $case->retryDelayMs) * 1000); $request = $this->translator->toRequest($case, $cookieJar); $response = $this->client->send($request); - $result = $this->buildResult($case, $response); + $result = $this->buildResult($case, $response, $request); $attempts++; } @@ -245,7 +246,7 @@ private function runSessionChain(array $cases): array usleep(500_000); $request = $this->translator->toRequest($case, $cookieJar); $response = $this->client->send($request); - $result = $this->buildResult($case, $response); + $result = $this->buildResult($case, $response, $request); $attempts++; } @@ -269,10 +270,10 @@ private function runSessionChain(array $cases): array /** * @param list $failures */ - private function buildResult(TestCase $case, Response $response, array $failures = []): Result + private function buildResult(TestCase $case, Response $response, ?Request $request, array $failures = []): Result { if ($response->isTransportError()) { - return Result::from($case, $response, ["cURL error: {$response->transportError}"]); + return Result::from($case, $response, ["cURL error: {$response->transportError}"], $request); } $found = $failures; @@ -283,7 +284,7 @@ private function buildResult(TestCase $case, Response $response, array $failures } } - return Result::from($case, $response, $found); + return Result::from($case, $response, $found, $request); } private function retryNonSession(TestCase $case, Result $result): Result @@ -306,7 +307,7 @@ private function retryNonSession(TestCase $case, Result $result): Result ); } $response = $this->client->send($request); - $result = $this->buildResult($case, $response); + $result = $this->buildResult($case, $response, $request); $attempts++; } $result->setRetryMetadata($attempts, $result->totalDurationSeconds); @@ -319,7 +320,7 @@ private function retryNonSession(TestCase $case, Result $result): Result try { $request = $this->translator->toRequest($case); $response = $this->client->send($request); - $result = $this->buildResult($case, $response); + $result = $this->buildResult($case, $response, $request); $result->setRetryMetadata(2, $result->totalDurationSeconds); } catch (VariableNotFoundException $e) { return Result::from( diff --git a/src/Http/Curl/CurlMultiClient.php b/src/Http/Curl/CurlMultiClient.php index e51b574..c1dd6c1 100644 --- a/src/Http/Curl/CurlMultiClient.php +++ b/src/Http/Curl/CurlMultiClient.php @@ -133,8 +133,9 @@ private function createHandle(Request $request): CurlHandle curl_setopt($handle, CURLOPT_CUSTOMREQUEST, $method); } - if ($request->body !== null && $request->method->allowsBody()) { - curl_setopt($handle, CURLOPT_POSTFIELDS, self::encodeBody($request)); + $encodedBody = $request->encodedBody(); + if ($encodedBody !== null) { + curl_setopt($handle, CURLOPT_POSTFIELDS, $encodedBody); } $headerLines = self::buildHeaderLines($request); @@ -150,52 +151,14 @@ private function createHandle(Request $request): CurlHandle */ private static function buildHeaderLines(Request $request): array { - $headers = $request->headers; - $hasBody = $request->body !== null && $request->method->allowsBody(); - $rawBody = is_string($request->body); - - if ($hasBody && !$rawBody) { - $hasContentType = false; - foreach (array_keys($headers) as $key) { - if (strtolower($key) === 'content-type') { - $hasContentType = true; - break; - } - } - if (!$hasContentType) { - $headers['Content-Type'] = $request->sendAsJson - ? 'application/json' - : 'application/x-www-form-urlencoded'; - } - } - $lines = []; - foreach ($headers as $name => $value) { + foreach ($request->effectiveHeaders() as $name => $value) { $lines[] = "{$name}: {$value}"; } return $lines; } - private static function encodeBody(Request $request): string - { - $body = $request->body; - if (is_string($body)) { - return $body; - } - if ($body === null) { - return ''; - } - - if ($request->sendAsJson) { - $json = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - - return $json === false ? '' : $json; - } - - return http_build_query($body); - } - private static function parseResponse(CurlHandle $handle, string $raw, float $duration): Response { $statusCode = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE); diff --git a/src/Http/Request.php b/src/Http/Request.php index d494780..94a99f4 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -22,6 +22,63 @@ public function __construct( public string $userAgent = 'StromcomSmokeTest/1.0', ) {} + public function hasBody(): bool + { + return $this->body !== null && $this->method->allowsBody(); + } + + public function bodyEncoding(): ?string + { + if (!$this->hasBody()) { + return null; + } + + return is_string($this->body) ? 'raw' : ($this->sendAsJson ? 'json' : 'form'); + } + + public function encodedBody(): ?string + { + if (!$this->hasBody()) { + return null; + } + $body = $this->body; + if (is_string($body)) { + return $body; + } + if ($body === null) { + return null; + } + if (!$this->sendAsJson) { + return http_build_query($body); + } + + $json = json_encode($body, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + return $json === false ? '' : $json; + } + + /** + * @return array Headers as actually sent, including the implicit Content-Type. + */ + public function effectiveHeaders(): array + { + $headers = $this->headers; + if (!$this->hasBody() || is_string($this->body)) { + return $headers; + } + + foreach (array_keys($headers) as $name) { + if (strtolower($name) === 'content-type') { + return $headers; + } + } + $headers['Content-Type'] = $this->sendAsJson + ? 'application/json' + : 'application/x-www-form-urlencoded'; + + return $headers; + } + public function withUrl(string $url): self { return new self( diff --git a/src/Reporting/ConsoleReporter.php b/src/Reporting/ConsoleReporter.php index a174e6f..475d1a4 100644 --- a/src/Reporting/ConsoleReporter.php +++ b/src/Reporting/ConsoleReporter.php @@ -92,6 +92,12 @@ public function onResult(Result $result, int $current, int $total): void foreach ($result->failures as $failure) { echo ' ' . str_repeat(' ', mb_strlen($indent)) . $style->red("↳ {$failure}") . PHP_EOL; } + $source = $result->case->source; + if ($source !== null) { + $cwd = getcwd(); + $location = $source->describe($cwd === false ? null : $cwd); + echo ' ' . str_repeat(' ', mb_strlen($indent)) . $style->dim("at {$location}") . PHP_EOL; + } if ($this->verbose) { $this->printVerbose($result); } diff --git a/src/Reporting/GithubSummaryReporter.php b/src/Reporting/GithubSummaryReporter.php index 19e118b..e8c8ad9 100644 --- a/src/Reporting/GithubSummaryReporter.php +++ b/src/Reporting/GithubSummaryReporter.php @@ -15,7 +15,7 @@ final class GithubSummaryReporter implements ReporterInterface public function __construct(?string $environment = null) { - $this->json = new JsonReporter(environment: $environment); + $this->json = new JsonReporter(environment: $environment, includePayloads: false); $this->markdown = new MarkdownReporter(json: $this->json, environment: $environment); } diff --git a/src/Reporting/HtmlReporter.php b/src/Reporting/HtmlReporter.php new file mode 100644 index 0000000..7c4045d --- /dev/null +++ b/src/Reporting/HtmlReporter.php @@ -0,0 +1,93 @@ +json = $json ?? new JsonReporter( + environment: $environment, + concurrency: $concurrency, + basePath: $basePath, + includePayloads: $includePayloads, + payloads: $payloads, + ); + } + + public function onStart(array $groups, int $totalTests): void {} + + public function onResult(Result $result, int $current, int $total): void {} + + public function onEnd(Report $report): void + { + if ($this->outputPath === null) { + return; + } + $this->write($this->build($this->json->generate($report)), $this->outputPath); + } + + /** + * @param array $data + */ + public function build(array $data): string + { + $template = @file_get_contents(self::TEMPLATE); + if ($template === false) { + throw new ConfigException('Failed to read HTML report template: ' . self::TEMPLATE); + } + + $json = json_encode( + $data, + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR | JSON_HEX_TAG | JSON_HEX_AMP, + ); + + return strtr($template, [ + '__SMOKE_TITLE__' => htmlspecialchars($this->titleFor($data), ENT_QUOTES, 'UTF-8'), + '__SMOKE_DATA__' => $json, + ]); + } + + /** + * @param array $data + */ + private function titleFor(array $data): string + { + $meta = $data['meta'] ?? null; + $environment = is_array($meta) ? ($meta['environment'] ?? null) : null; + + return is_string($environment) && $environment !== '' + ? "{$this->title} — {$environment}" + : $this->title; + } + + private function write(string $html, string $filePath): void + { + $dir = dirname($filePath); + if (!is_dir($dir) && !@mkdir($dir, 0o755, true) && !is_dir($dir)) { + throw new ConfigException("Failed to create directory: {$dir}"); + } + if (file_put_contents($filePath, $html) === false) { + throw new ConfigException("Failed to write HTML report: {$filePath}"); + } + } +} diff --git a/src/Reporting/JsonReporter.php b/src/Reporting/JsonReporter.php index f36f330..25a3d7e 100644 --- a/src/Reporting/JsonReporter.php +++ b/src/Reporting/JsonReporter.php @@ -7,19 +7,27 @@ use Stromcom\HttpSmoke\Exception\ConfigException; use Stromcom\HttpSmoke\Execution\Report; use Stromcom\HttpSmoke\Execution\Result; +use Stromcom\HttpSmoke\Reporting\Support\PayloadFormatter; final class JsonReporter implements ReporterInterface { - public const int SCHEMA_VERSION = 2; + public const int SCHEMA_VERSION = 3; /** @var array|null */ private ?array $lastData = null; + private readonly PayloadFormatter $payloads; + public function __construct( private readonly ?string $outputPath = null, private readonly ?string $environment = null, private readonly int $concurrency = 1, - ) {} + private readonly ?string $basePath = null, + private readonly bool $includePayloads = true, + ?PayloadFormatter $payloads = null, + ) { + $this->payloads = $payloads ?? new PayloadFormatter(); + } public function onStart(array $groups, int $totalTests): void {} @@ -83,6 +91,8 @@ private function buildMeta(Report $report): array 'generated_at' => date('c'), 'duration_s' => round($report->getTotalDuration(), 3), 'concurrency' => $this->concurrency, + 'base_path' => $this->basePath, + 'payloads' => $this->includePayloads, 'summary' => [ 'total' => $report->getTotalCount(), 'passed' => $report->getPassedCount(), @@ -161,8 +171,15 @@ private function buildEntry(Result $result, ?string $sessionLabel): array 'attempts' => $result->attempts, 'total_duration_ms' => (int) round($result->totalDurationSeconds * 1000), 'session' => $sessionLabel !== null ? ['label' => $sessionLabel] : null, + 'source' => $this->source($result), 'failures' => $result->failures, 'skip_reason' => $result->isSkipped() ? $result->skipReason : null, + 'request' => $this->includePayloads && $result->request !== null + ? $this->payloads->request($result->request) + : null, + 'response' => $this->includePayloads && !$result->isSkipped() + ? $this->payloads->response($result->response) + : null, ]; } @@ -180,10 +197,28 @@ private function buildChainStep(Result $result): array 'duration_ms' => (int) round($result->response->durationSeconds * 1000), 'attempts' => $result->attempts, 'total_duration_ms' => (int) round($result->totalDurationSeconds * 1000), + 'source' => $this->source($result), 'failures' => $result->failures, ]; } + /** + * @return array|null + */ + private function source(Result $result): ?array + { + $source = $result->case->source; + if ($source === null) { + return null; + } + + return [ + 'file' => $source->relativeTo($this->basePath), + 'absolute_path' => $source->file, + 'line' => $source->line, + ]; + } + private static function extractSessionLabel(?string $sessionId): ?string { if ($sessionId === null) { diff --git a/src/Reporting/MarkdownReporter.php b/src/Reporting/MarkdownReporter.php index 00e5195..ef5f6f6 100644 --- a/src/Reporting/MarkdownReporter.php +++ b/src/Reporting/MarkdownReporter.php @@ -18,7 +18,7 @@ public function __construct( ?JsonReporter $json = null, ?string $environment = null, ) { - $this->json = $json ?? new JsonReporter(environment: $environment); + $this->json = $json ?? new JsonReporter(environment: $environment, includePayloads: false); } public function onStart(array $groups, int $totalTests): void {} @@ -135,6 +135,12 @@ public function build(array $data): string private function buildFailureDetails(array $test): string { $parts = []; + $source = $test['source'] ?? null; + if (is_array($source)) { + $file = self::esc(self::asString($source['file'] ?? '')); + $line = self::asInt($source['line'] ?? 0); + $parts[] = "`{$file}:{$line}`"; + } $chain = $test['chain_context'] ?? null; if (is_array($chain) && $chain !== []) { $steps = []; diff --git a/src/Reporting/Support/PayloadFormatter.php b/src/Reporting/Support/PayloadFormatter.php new file mode 100644 index 0000000..7f5324e --- /dev/null +++ b/src/Reporting/Support/PayloadFormatter.php @@ -0,0 +1,139 @@ +|null, headers: array, body_encoding: string|null, body: BodyPayload|null, timeout_s: int, cookie_jar: bool, insecure_tls: bool, user_agent: string} + * @phpstan-type ResponsePayload array{status_code: int|null, headers: array, content_type: string|null, body: BodyPayload|null, transport_error: string|null} + */ +final readonly class PayloadFormatter +{ + public const int DEFAULT_MAX_BYTES = 16384; + + public const string REDACTED = '«redacted»'; + + /** @var list */ + public const array DEFAULT_REDACTED_HEADERS = [ + 'authorization', + 'proxy-authorization', + 'cookie', + 'set-cookie', + 'x-api-key', + 'x-auth-token', + 'x-csrf-token', + ]; + + /** @var list */ + private array $redactedHeaders; + + /** + * @param list $redactedHeaders + */ + public function __construct( + private int $maxBytes = self::DEFAULT_MAX_BYTES, + array $redactedHeaders = self::DEFAULT_REDACTED_HEADERS, + ) { + $this->redactedHeaders = array_values(array_map(strtolower(...), $redactedHeaders)); + } + + /** + * @return RequestPayload + */ + public function request(Request $request): array + { + return [ + 'method' => $request->method->value, + 'url' => $request->url, + 'query' => self::query($request->url), + 'headers' => $this->headers($request->effectiveHeaders()), + 'body_encoding' => $request->bodyEncoding(), + 'body' => $this->body($request->encodedBody()), + 'timeout_s' => $request->timeoutSeconds, + 'cookie_jar' => $request->cookieJarPath !== null, + 'insecure_tls' => $request->insecureTls, + 'user_agent' => $request->userAgent, + ]; + } + + /** + * @return ResponsePayload + */ + public function response(Response $response): array + { + return [ + 'status_code' => $response->statusCode > 0 ? $response->statusCode : null, + 'headers' => $this->headers($response->headers), + 'content_type' => $response->headers['content-type'] ?? null, + 'body' => $this->body($response->body), + 'transport_error' => $response->transportError, + ]; + } + + /** + * @param array $headers + * @return array + */ + private function headers(array $headers): array + { + $out = []; + foreach ($headers as $name => $value) { + $out[$name] = in_array(strtolower($name), $this->redactedHeaders, true) + ? self::REDACTED + : $value; + } + + return $out; + } + + /** + * @return BodyPayload|null + */ + private function body(?string $body): ?array + { + if ($body === null || $body === '') { + return null; + } + + $size = strlen($body); + if (!mb_check_encoding($body, 'UTF-8')) { + return ['text' => null, 'size_bytes' => $size, 'truncated' => false, 'binary' => true]; + } + + $truncated = $size > $this->maxBytes; + + return [ + 'text' => $truncated ? mb_strcut($body, 0, $this->maxBytes) : $body, + 'size_bytes' => $size, + 'truncated' => $truncated, + 'binary' => false, + ]; + } + + /** + * @return array|null + */ + private static function query(string $url): ?array + { + $queryString = parse_url($url, PHP_URL_QUERY); + if (!is_string($queryString) || $queryString === '') { + return null; + } + + $pairs = []; + foreach (explode('&', $queryString) as $pair) { + if ($pair === '') { + continue; + } + [$name, $value] = array_pad(explode('=', $pair, 2), 2, ''); + $pairs[urldecode($name)] = urldecode($value); + } + + return $pairs === [] ? null : $pairs; + } +} diff --git a/src/Reporting/templates/report.html b/src/Reporting/templates/report.html new file mode 100644 index 0000000..d657b61 --- /dev/null +++ b/src/Reporting/templates/report.html @@ -0,0 +1,550 @@ + + + + + +__SMOKE_TITLE__ + + + +
+
+
+

__SMOKE_TITLE__

+
+
+ +
+ +
+
+ +
+ +
+ + + + +
+ + + + + + + +
+ +
+

No test matches the current filters.

+
+ + + + + diff --git a/tests/Unit/Definition/SourceLocationTest.php b/tests/Unit/Definition/SourceLocationTest.php new file mode 100644 index 0000000..24f62b6 --- /dev/null +++ b/tests/Unit/Definition/SourceLocationTest.php @@ -0,0 +1,48 @@ +group('api') + ->get('https://example.test/a')->expectStatus(200) + ->get('https://example.test/b')->expectStatus(200); + + $expectedFirstLine = __LINE__ - 3; + $cases = $suite->getCasesByGroup()['api']; + + self::assertNotNull($cases[0]->source); + self::assertSame(__FILE__, $cases[0]->source->file); + self::assertSame($expectedFirstLine, $cases[0]->source->line); + self::assertSame($expectedFirstLine + 1, $cases[1]->source?->line); + } + + #[Test] + #[TestWith(['/project', 'tests/SmokeHttp/api.php'])] + #[TestWith(['/project/', 'tests/SmokeHttp/api.php'])] + #[TestWith(['/elsewhere', '/project/tests/SmokeHttp/api.php'])] + #[TestWith([null, '/project/tests/SmokeHttp/api.php'])] + public function relative_paths_are_stripped_only_when_inside_the_base_path( + ?string $basePath, + string $expected, + ): void { + $source = new SourceLocation('/project/tests/SmokeHttp/api.php', 42); + + self::assertSame($expected, $source->relativeTo($basePath)); + self::assertSame("{$expected}:42", $source->describe($basePath)); + } +} diff --git a/tests/Unit/Reporting/HtmlReporterTest.php b/tests/Unit/Reporting/HtmlReporterTest.php new file mode 100644 index 0000000..a85e946 --- /dev/null +++ b/tests/Unit/Reporting/HtmlReporterTest.php @@ -0,0 +1,185 @@ + */ + private const array DATA = [ + 'meta' => [ + 'schema_version' => 3, + 'payloads' => true, + 'environment' => 'staging', + 'generated_at' => '2026-08-03T10:00:00+02:00', + 'duration_s' => 1.25, + 'concurrency' => 10, + 'summary' => ['total' => 2, 'passed' => 1, 'failed' => 1, 'skipped' => 0, 'success' => false], + ], + 'groups' => [ + [ + 'name' => 'api.orders', + 'summary' => ['total' => 2, 'passed' => 1, 'failed' => 1, 'skipped' => 0], + 'tests' => [ + [ + 'label' => 'create order', + 'method' => 'POST', + 'url' => 'https://example.test/orders', + 'status' => 'passed', + 'http_code' => 201, + 'duration_ms' => 120, + 'attempts' => 1, + 'total_duration_ms' => 120, + 'session' => ['label' => 'order flow'], + 'source' => [ + 'file' => 'tests/SmokeHttp/api/orders.php', + 'absolute_path' => '/project/tests/SmokeHttp/api/orders.php', + 'line' => 12, + ], + 'failures' => [], + 'skip_reason' => null, + 'chain_context' => [], + 'request' => [ + 'method' => 'POST', + 'url' => 'https://example.test/orders', + 'query' => null, + 'headers' => ['Authorization' => '«redacted»', 'Content-Type' => 'application/json'], + 'body_encoding' => 'json', + 'body' => [ + 'text' => '{"sku":"A-1"}', + 'size_bytes' => 13, + 'truncated' => false, + 'binary' => false, + ], + 'timeout_s' => 10, + 'cookie_jar' => true, + 'insecure_tls' => false, + 'user_agent' => 'StromcomSmokeTest/1.0', + ], + 'response' => [ + 'status_code' => 201, + 'headers' => ['content-type' => 'application/json'], + 'content_type' => 'application/json', + 'body' => [ + 'text' => '{"id":1}', + 'size_bytes' => 8, + 'truncated' => false, + 'binary' => false, + ], + 'transport_error' => null, + ], + ], + [ + 'label' => 'fetch order', + 'method' => 'GET', + 'url' => 'https://example.test/orders/1', + 'status' => 'failed', + 'http_code' => 500, + 'duration_ms' => 300, + 'attempts' => 2, + 'total_duration_ms' => 640, + 'session' => ['label' => 'order flow'], + 'failures' => ['Expected status 200, got 500'], + 'skip_reason' => null, + 'chain_context' => [ + [ + 'label' => 'create order', + 'method' => 'POST', + 'url' => 'https://example.test/orders', + 'status' => 'passed', + 'http_code' => 201, + 'duration_ms' => 120, + 'attempts' => 1, + 'total_duration_ms' => 120, + 'failures' => [], + ], + ], + 'request' => null, + 'response' => null, + ], + ], + ], + ], + ]; + + #[Test] + public function build_produces_standalone_html_with_embedded_report_data(): void + { + $html = new HtmlReporter()->build(self::DATA); + + self::assertStringStartsWith('', $html); + self::assertStringNotContainsString('__SMOKE_DATA__', $html); + self::assertStringNotContainsString('__SMOKE_TITLE__', $html); + self::assertStringContainsString('HTTP Smoke Report — staging', $html); + self::assertStringContainsString('tests/SmokeHttp/api/orders.php', $html); + self::assertStringNotContainsString('src=', $html); + + self::assertSame(self::DATA, $this->extractData($html)); + } + + #[Test] + public function build_escapes_data_so_it_cannot_break_out_of_the_script_tag(): void + { + $data = self::DATA; + $data['groups'][0]['tests'][0]['label'] = ''; + + $html = new HtmlReporter()->build($data); + + self::assertStringNotContainsString('extractData($html)); + } + + #[Test] + public function on_end_writes_the_file_and_creates_missing_directories(): void + { + $path = sys_get_temp_dir() . '/http-smoke-html-' . bin2hex(random_bytes(4)) . '/nested/report.html'; + + $report = new Report(); + $report->addResult(Result::from( + new SmokeTestCase(group: 'api.orders', method: Method::GET, url: 'https://example.test/orders'), + new Response(200, '{}', [], 0.12), + [], + )); + + $reporter = new HtmlReporter(outputPath: $path, environment: 'staging'); + $reporter->onEnd($report); + + self::assertFileExists($path); + $html = (string) file_get_contents($path); + self::assertStringContainsString('staging', $html); + self::assertStringContainsString('api.orders', $html); + + unlink($path); + rmdir(dirname($path)); + rmdir(dirname($path, 2)); + } + + /** + * @return array + */ + private function extractData(string $html): array + { + self::assertSame( + 1, + preg_match('##s', $html, $m), + ); + + $decoded = json_decode($m[1], true, 512, JSON_THROW_ON_ERROR); + self::assertIsArray($decoded); + + /** @var array $decoded */ + return $decoded; + } +} diff --git a/tests/Unit/Reporting/JsonReporterTest.php b/tests/Unit/Reporting/JsonReporterTest.php new file mode 100644 index 0000000..900c97b --- /dev/null +++ b/tests/Unit/Reporting/JsonReporterTest.php @@ -0,0 +1,134 @@ +generate(self::report()); + + self::assertSame(JsonReporter::SCHEMA_VERSION, $this->arr($data['meta'])['schema_version']); + self::assertTrue($this->arr($data['meta'])['payloads']); + + $test = $this->firstTest($data); + + self::assertSame([ + 'method' => 'POST', + 'url' => 'https://example.test/orders', + 'query' => null, + 'headers' => ['Authorization' => PayloadFormatter::REDACTED, 'Content-Type' => 'application/json'], + 'body_encoding' => 'json', + 'body' => ['text' => '{"sku":"A-1"}', 'size_bytes' => 13, 'truncated' => false, 'binary' => false], + 'timeout_s' => 10, + 'cookie_jar' => false, + 'insecure_tls' => false, + 'user_agent' => 'StromcomSmokeTest/1.0', + ], $test['request']); + + self::assertSame( + ['file' => 'orders.php', 'absolute_path' => '/project/orders.php', 'line' => 7], + $test['source'], + ); + + self::assertSame([ + 'status_code' => 201, + 'headers' => ['content-type' => 'application/json'], + 'content_type' => 'application/json', + 'body' => ['text' => '{"id":1}', 'size_bytes' => 8, 'truncated' => false, 'binary' => false], + 'transport_error' => null, + ], $test['response']); + } + + #[Test] + public function payloads_can_be_omitted(): void + { + $data = new JsonReporter(includePayloads: false)->generate(self::report()); + $test = $this->firstTest($data); + + self::assertFalse($this->arr($data['meta'])['payloads']); + self::assertNull($test['request']); + self::assertNull($test['response']); + self::assertSame('create order', $test['label']); + } + + #[Test] + public function skipped_tests_carry_no_payloads(): void + { + $report = new Report(); + $report->addResult(Result::skipped( + new SmokeTestCase(group: 'api.orders', method: Method::GET, url: 'https://example.test/orders'), + 'Session chain: previous request failed — skipping remaining', + )); + + $test = $this->firstTest(new JsonReporter()->generate($report)); + + self::assertSame('skipped', $test['status']); + self::assertNull($test['request']); + self::assertNull($test['response']); + } + + private static function report(): Report + { + $report = new Report(); + $report->addResult(Result::from( + new SmokeTestCase( + group: 'api.orders', + method: Method::POST, + url: 'https://example.test/orders', + label: 'create order', + source: new SourceLocation('/project/orders.php', 7), + ), + new Response(201, '{"id":1}', ['content-type' => 'application/json'], 0.12), + [], + new Request( + method: Method::POST, + url: 'https://example.test/orders', + headers: ['Authorization' => 'Bearer secret'], + body: ['sku' => 'A-1'], + sendAsJson: true, + ), + )); + + return $report; + } + + /** + * @param array $data + * @return array + */ + private function firstTest(array $data): array + { + $groups = $this->arr($data['groups']); + $tests = $this->arr($this->arr($groups[0])['tests']); + + return $this->arr($tests[0]); + } + + /** + * @return array + */ + private function arr(mixed $value): array + { + self::assertIsArray($value); + + return $value; + } +} diff --git a/tests/Unit/Reporting/PayloadFormatterTest.php b/tests/Unit/Reporting/PayloadFormatterTest.php new file mode 100644 index 0000000..b13b9f2 --- /dev/null +++ b/tests/Unit/Reporting/PayloadFormatterTest.php @@ -0,0 +1,154 @@ +request(new Request( + method: Method::POST, + url: 'https://example.test/orders?page=2&q=a%20b', + body: ['name' => 'Ada'], + sendAsJson: true, + )); + + self::assertSame('json', $data['body_encoding']); + self::assertSame(['page' => '2', 'q' => 'a b'], $data['query']); + self::assertSame('application/json', $data['headers']['Content-Type']); + self::assertSame( + ['text' => '{"name":"Ada"}', 'size_bytes' => 14, 'truncated' => false, 'binary' => false], + $data['body'], + ); + } + + #[Test] + public function form_request_reports_the_url_encoded_body(): void + { + $data = new PayloadFormatter()->request(new Request( + method: Method::POST, + url: 'https://example.test/orders', + body: ['name' => 'Ada'], + )); + + self::assertSame('form', $data['body_encoding']); + self::assertSame('application/x-www-form-urlencoded', $data['headers']['Content-Type']); + self::assertSame( + ['text' => 'name=Ada', 'size_bytes' => 8, 'truncated' => false, 'binary' => false], + $data['body'], + ); + } + + #[Test] + public function get_request_has_no_body_and_no_query(): void + { + $data = new PayloadFormatter()->request(new Request( + method: Method::GET, + url: 'https://example.test/orders', + )); + + self::assertNull($data['body']); + self::assertNull($data['body_encoding']); + self::assertNull($data['query']); + } + + #[Test] + public function sensitive_headers_are_redacted_in_both_directions(): void + { + $formatter = new PayloadFormatter(); + + $request = $formatter->request(new Request( + method: Method::GET, + url: 'https://example.test/me', + headers: ['Authorization' => 'Bearer secret-token', 'X-Trace' => 'keep-me'], + )); + self::assertSame( + ['Authorization' => PayloadFormatter::REDACTED, 'X-Trace' => 'keep-me'], + $request['headers'], + ); + + $response = $formatter->response(new Response( + 200, + '{}', + ['set-cookie' => 'session=abc', 'content-type' => 'application/json'], + 0.1, + )); + self::assertSame( + ['set-cookie' => PayloadFormatter::REDACTED, 'content-type' => 'application/json'], + $response['headers'], + ); + self::assertSame('application/json', $response['content_type']); + } + + #[Test] + public function custom_redaction_list_replaces_the_default(): void + { + $data = new PayloadFormatter(redactedHeaders: ['x-trace'])->request(new Request( + method: Method::GET, + url: 'https://example.test/me', + headers: ['Authorization' => 'Bearer token', 'X-Trace' => 'secret'], + )); + + self::assertSame( + ['Authorization' => 'Bearer token', 'X-Trace' => PayloadFormatter::REDACTED], + $data['headers'], + ); + } + + #[Test] + public function long_bodies_are_truncated_and_report_the_original_size(): void + { + $data = new PayloadFormatter(maxBytes: 100)->response( + new Response(200, str_repeat('x', 5000), [], 0.1), + ); + + self::assertSame( + ['text' => str_repeat('x', 100), 'size_bytes' => 5000, 'truncated' => true, 'binary' => false], + $data['body'], + ); + } + + #[Test] + public function multibyte_bodies_are_not_cut_mid_character(): void + { + $data = new PayloadFormatter(maxBytes: 5)->response(new Response(200, 'ěščřž', [], 0.1)); + + self::assertSame( + ['text' => 'ěš', 'size_bytes' => 10, 'truncated' => true, 'binary' => false], + $data['body'], + ); + } + + #[Test] + public function binary_bodies_are_reported_without_text(): void + { + $data = new PayloadFormatter()->response(new Response(200, "\x00\x01\xff\xfe", [], 0.1)); + + self::assertSame( + ['text' => null, 'size_bytes' => 4, 'truncated' => false, 'binary' => true], + $data['body'], + ); + } + + #[Test] + public function transport_error_is_carried_over(): void + { + $data = new PayloadFormatter()->response(Response::transportFailure('(28) timeout', 10.0)); + + self::assertNull($data['status_code']); + self::assertNull($data['body']); + self::assertSame('(28) timeout', $data['transport_error']); + } +}