diff --git a/openapi.yaml b/openapi.yaml index 772ace90..5f5a5af6 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -13388,7 +13388,6 @@ components: - expected - actual - data_collection_accepted - - screenshot properties: before_error: type: string @@ -13404,10 +13403,11 @@ components: description: What actually happened data_collection_accepted: type: boolean - description: Required acceptance of collecting screenshot and diagnostic error data + description: Required acceptance of collecting diagnostic error data and a screenshot when one can be attached screenshot: type: string - description: PNG, JPEG, or WebP data URI of the current app viewport + nullable: true + description: Optional PNG, JPEG, or WebP data URI of the current app viewport. Reports are accepted without an attachment when capture or upload fails. route_path: type: string nullable: true @@ -13518,6 +13518,7 @@ components: nullable: true screenshot: type: object + nullable: true additionalProperties: true answers: type: object diff --git a/scripts/edge-gateway-e2e.mjs b/scripts/edge-gateway-e2e.mjs index 7253e0f5..ae5e49d6 100644 --- a/scripts/edge-gateway-e2e.mjs +++ b/scripts/edge-gateway-e2e.mjs @@ -928,22 +928,20 @@ async function main() { { timeoutMs: 20_000, message: "Browser shell never closed cleanly." } ); - const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, { - token: authToken, - }); - const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions) - ? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || "")) - : []; + await waitForCondition( + async () => { + const logsAfterShell = await apiRequest(baseUrl, "GET", `/edge-gateways/${gatewayId}/logs`, { + token: authToken, + }); + const shellTranscripts = Array.isArray(logsAfterShell?.data?.shell_sessions) + ? logsAfterShell.data.shell_sessions.map((session) => String(session?.transcript || "")) + : []; + const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []); - assert.ok( - shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")), - "Gateway logs page did not persist the shell transcript." - ); - - const timelineMessages = collectMessages(logsAfterShell?.data?.timeline || []); - assert.ok( - timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"), - "Gateway logs page did not include the shell close audit event." + return shellTranscripts.some((transcript) => transcript.includes("edge-e2e-shell")) + && timelineMessages.includes("GATEWAY_SHELL_SESSION_CLOSED"); + }, + { timeoutMs: 30_000, message: "Gateway logs page did not persist the shell transcript and close audit event." } ); process.stdout.write("Edge gateway E2E smoke completed successfully.\n"); diff --git a/services/nginx/app/classes/error_report_service.php b/services/nginx/app/classes/error_report_service.php index 10507955..d3e32e67 100644 --- a/services/nginx/app/classes/error_report_service.php +++ b/services/nginx/app/classes/error_report_service.php @@ -92,12 +92,17 @@ class error_report_service throw new RuntimeException('Data collection acceptance is required.'); } - $screenshot = self::decodeScreenshotDataUri((string)($payload['screenshot'] ?? '')); - $storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']); $context = is_array($payload['context'] ?? null) ? $payload['context'] : []; + $storedScreenshot = $this->storeOptionalScreenshot($payload['screenshot'] ?? null, $context); $requestErrors = $this->boundedArray($payload['request_errors'] ?? ($context['request_errors'] ?? []), 25); $vueErrors = $this->boundedArray($payload['vue_errors'] ?? ($context['vue_errors'] ?? []), 25); $runtimeContext = $this->runtimeContext($payload, $context); + $runtimeContext['screenshot_attachment'] = [ + 'status' => $storedScreenshot['status'], + 'attached' => $storedScreenshot['key'] !== '', + 'mime_type' => $storedScreenshot['mime_type'] !== '' ? $storedScreenshot['mime_type'] : null, + 'size_bytes' => (int)$storedScreenshot['size_bytes'], + ]; $this->execute( "INSERT INTO error_reports ( @@ -295,6 +300,67 @@ class error_report_service return $value === true || $value === 1 || $value === '1' || $value === 'true'; } + private function storeOptionalScreenshot(mixed $value, array $context): array + { + if (!is_scalar($value) && !$value instanceof \Stringable && $value !== null) { + return $this->emptyScreenshotAttachment('invalid'); + } + + $dataUri = trim((string)($value ?? '')); + if ($dataUri === '') { + return $this->emptyScreenshotAttachment($this->contextScreenshotStatus($context) ?? 'not_provided'); + } + + try { + $screenshot = self::decodeScreenshotDataUri($dataUri); + } catch (RuntimeException $exception) { + $message = strtolower($exception->getMessage()); + return $this->emptyScreenshotAttachment(str_contains($message, 'too large') ? 'too_large' : 'invalid'); + } + + try { + $storedScreenshot = $this->store->storeScreenshot($screenshot['mime_type'], $screenshot['contents']); + } catch (Throwable) { + return $this->emptyScreenshotAttachment('storage_failed'); + } + + return [ + 'key' => (string)($storedScreenshot['key'] ?? ''), + 'mime_type' => (string)($storedScreenshot['mime_type'] ?? $screenshot['mime_type']), + 'size_bytes' => (int)($storedScreenshot['size_bytes'] ?? $screenshot['size_bytes']), + 'status' => 'stored', + ]; + } + + private function emptyScreenshotAttachment(string $status): array + { + return [ + 'key' => '', + 'mime_type' => '', + 'size_bytes' => 0, + 'status' => $status, + ]; + } + + private function contextScreenshotStatus(array $context): ?string + { + $attachment = $context['screenshot_attachment'] ?? null; + $status = is_array($attachment) ? ($attachment['status'] ?? null) : null; + $status ??= $context['screenshot_capture_status'] ?? $context['screenshot_status'] ?? null; + + return $this->normalizeEmptyScreenshotStatus($status); + } + + private function normalizeEmptyScreenshotStatus(mixed $status): ?string + { + $status = strtolower(trim((string)$status)); + if (in_array($status, ['capture_failed', 'not_provided'], true)) { + return $status; + } + + return null; + } + private function runtimeContext(array $payload, array $context): array { return [ @@ -432,6 +498,10 @@ class error_report_service private function publicReport(array $row, bool $includeDetail): array { + $screenshotMimeType = trim((string)($row['screenshot_mime_type'] ?? '')); + $screenshotSizeBytes = isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0; + $hasScreenshot = $screenshotMimeType !== '' && $screenshotSizeBytes > 0; + $report = [ 'id' => (int)$row['id'], 'status' => (string)$row['status'], @@ -449,10 +519,10 @@ class error_report_service 'release_trace_id' => $row['release_trace_id'] ?? null, 'frontend_version' => $row['frontend_version'] ?? null, 'api_version' => $row['api_version'] ?? null, - 'screenshot' => [ - 'mime_type' => $row['screenshot_mime_type'] ?? null, - 'size_bytes' => isset($row['screenshot_size_bytes']) ? (int)$row['screenshot_size_bytes'] : 0, - ], + 'screenshot' => $hasScreenshot ? [ + 'mime_type' => $screenshotMimeType, + 'size_bytes' => $screenshotSizeBytes, + ] : null, 'answers' => [ 'before_error' => $row['before_error'] ?? '', 'expected' => $row['expected'] ?? '', @@ -467,8 +537,11 @@ class error_report_service ]; if ($includeDetail) { - $report['screenshot']['url'] = $this->store->screenshotUrl((string)($row['screenshot_object_key'] ?? '')); - $report['screenshot']['object_key'] = $row['screenshot_object_key'] ?? null; + if ($hasScreenshot) { + $objectKey = trim((string)($row['screenshot_object_key'] ?? '')); + $report['screenshot']['url'] = $this->store->screenshotUrl($objectKey); + $report['screenshot']['object_key'] = $objectKey !== '' ? $objectKey : null; + } $report['request_errors'] = $this->jsonDecode($row['request_errors_json'] ?? null); $report['vue_errors'] = $this->jsonDecode($row['vue_errors_json'] ?? null); $report['runtime_context'] = $this->jsonDecode($row['runtime_context_json'] ?? null); diff --git a/services/nginx/app/openapi.yaml b/services/nginx/app/openapi.yaml index 77e222e6..dd1674c1 100644 --- a/services/nginx/app/openapi.yaml +++ b/services/nginx/app/openapi.yaml @@ -13388,7 +13388,6 @@ components: - expected - actual - data_collection_accepted - - screenshot properties: before_error: type: string @@ -13404,10 +13403,11 @@ components: description: What actually happened data_collection_accepted: type: boolean - description: Required acceptance of collecting screenshot and diagnostic error data + description: Required acceptance of collecting diagnostic error data and a screenshot when one can be attached screenshot: type: string - description: PNG, JPEG, or WebP data URI of the current app viewport + nullable: true + description: Optional PNG, JPEG, or WebP data URI of the current app viewport. Reports are accepted without an attachment when capture or upload fails. route_path: type: string nullable: true @@ -13518,6 +13518,7 @@ components: nullable: true screenshot: type: object + nullable: true additionalProperties: true answers: type: object diff --git a/services/nginx/app/tests/Api/ErrorReportsApiTest.php b/services/nginx/app/tests/Api/ErrorReportsApiTest.php new file mode 100644 index 00000000..91f92f59 --- /dev/null +++ b/services/nginx/app/tests/Api/ErrorReportsApiTest.php @@ -0,0 +1,97 @@ + 'Opening the orders page', + 'expected' => 'The orders should load', + 'actual' => 'The page showed an error', + 'data_collection_accepted' => true, + 'data_collection_policy_version' => 'error-report-v1', + 'route_path' => '/admin/orders', + 'page_url' => 'https://app.example.test/admin/orders', + 'release_trace_id' => 'trace-error-report-test', + 'frontend_version' => 'frontend-test', + 'api_version' => 'api-test', + 'request_errors' => [ + ['method' => 'GET', 'url' => '/orders', 'statusCode' => 500], + ], + 'vue_errors' => [ + ['type' => 'vue_component_error', 'payload' => ['message' => 'Render failed']], + ], + 'context' => [ + 'viewport' => ['width' => 1280, 'height' => 720], + 'user_agent' => 'ErrorReportsApiTest', + 'captured_at' => '2026-07-06T10:00:00.000Z', + 'data_collection_policy_version' => 'error-report-v1', + ], + ], $overrides); +} + +function error_report_api_cleanup(array $report): void +{ + $id = (int)($report['id'] ?? 0); + if ($id > 0) { + api_fixtures()->cleanupDeleteById('error_reports', $id); + } +} + +it('creates error reports when screenshot capture failed', function (): void { + api_test_covers('POST /error-reports', 'happy'); + + $session = api_fixtures()->createUserSession(); + $response = api_client()->post('/error-reports', error_report_api_payload([ + 'screenshot' => null, + 'context' => [ + 'screenshot_attachment' => ['status' => 'capture_failed'], + ], + ]), $session['headers']); + + $response + ->assertStatus(201) + ->assertEnvelope() + ->assertSuccess(); + + $report = $response->data(); + expect($report['screenshot'])->toBeNull(); + expect($report['answers']['before_error'])->toBe('Opening the orders page'); + expect($report['request_error_count'])->toBe(1); + expect($report['vue_error_count'])->toBe(1); + expect($report['runtime_context']['screenshot_attachment'])->toMatchArray([ + 'status' => 'capture_failed', + 'attached' => false, + 'mime_type' => null, + 'size_bytes' => 0, + ]); + + error_report_api_cleanup($report); +}); + +it('creates error reports when an optional screenshot payload is invalid', function (): void { + api_test_covers('POST /error-reports', 'invalid optional screenshot'); + + $session = api_fixtures()->createUserSession(); + $response = api_client()->post('/error-reports', error_report_api_payload([ + 'screenshot' => 'data:text/plain;base64,' . base64_encode('not an image'), + ]), $session['headers']); + + $response + ->assertStatus(201) + ->assertEnvelope() + ->assertSuccess(); + + $report = $response->data(); + expect($report['screenshot'])->toBeNull(); + expect($report['runtime_context']['screenshot_attachment'])->toMatchArray([ + 'status' => 'invalid', + 'attached' => false, + 'mime_type' => null, + 'size_bytes' => 0, + ]); + + error_report_api_cleanup($report); +}); diff --git a/services/nginx/app/tests/Unit/ErrorReports/ErrorReportTest.php b/services/nginx/app/tests/Unit/ErrorReports/ErrorReportTest.php index b4fa2684..ffc678e6 100644 --- a/services/nginx/app/tests/Unit/ErrorReports/ErrorReportTest.php +++ b/services/nginx/app/tests/Unit/ErrorReports/ErrorReportTest.php @@ -76,4 +76,6 @@ it('defines error report schema, routes, permissions, storage, and OpenAPI docs' expect($openapi)->toContain('/error-reports:'); expect($openapi)->toContain('ErrorReportSubmissionRequest'); expect($openapi)->toContain('ErrorReportStatusUpdateRequest'); + expect($openapi)->not->toContain(" - screenshot\n"); + expect($openapi)->toContain('Reports are accepted without an attachment when capture or upload fails.'); });