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']); + } +}