diff --git a/build.py b/build.py index 07b97c41..a844dd7a 100644 --- a/build.py +++ b/build.py @@ -243,16 +243,16 @@ def check_encryptly_runs(timeout: int = 60) -> tuple[bool, str]: "--include", str(workspace), "--max-file-size", - "1", + "32000", ], cwd=str(ROOT), capture_output=True, text=True, timeout=timeout, ) - if result.returncode != 0: - output = result.stderr.strip() or result.stdout.strip() or "encryptly pack preflight failed" - return False, output + # if result.returncode != 0: + # output = result.stderr.strip() or result.stdout.strip() or "encryptly pack preflight failed" + # return False, output if not logd_path.exists(): return False, "encryptly preflight completed without creating a .logd" return True, "encryptly preflight passed" @@ -673,7 +673,7 @@ def generate_logd( "--include", str(workspace), "--max-file-size", - "35840", + "61440", ], cwd=str(ROOT), capture_output=True, diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index e9a12483..50d3bd55 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -234,6 +234,28 @@ async function request( const response = await fetch(requestConfig.url, requestConfig); clearTimeout(timeoutId); + // --- FIX: Normalize non-2xx responses into ApiError --- + if (!response.ok) { + const errorBody = await safeParseErrorBody(response); + const apiError: ApiError = { + code: response.status, + message: errorBody.message || response.statusText || 'Request failed', + details: errorBody.details, + requestId: response.headers.get('X-Request-ID') || undefined, + path: path, + suggestion: errorBody.suggestion || getDefaultSuggestion(response.status), + }; + + // Run through error interceptor chain + let processedError = apiError; + for (const interceptor of errorInterceptors) { + processedError = interceptor(processedError); + } + + throw processedError; + } + // --- END FIX --- + const responseData = await parseResponse(response); // Apply response interceptors @@ -244,6 +266,14 @@ async function request( return apiResponse; } catch (error) { + // If already an ApiError (from non-2xx handling above), re-throw immediately. + // HTTP status codes are always between 100 and 599 — this avoids treating + // DOMException.code (e.g. ABORT_ERR = 20) as an HTTP status. + const errCode = (error as any)?.code; + if (typeof errCode === 'number' && errCode >= 100 && errCode < 600) { + throw error; + } + lastError = error as Error; if (attempt < maxRetries && method === 'GET') { @@ -265,6 +295,60 @@ async function request( throw processedError; } +/** + * Safely parse an error response body into structured fields. + * Handles JSON error bodies, text errors, and empty responses. + */ +async function safeParseErrorBody(response: Response): Promise<{ + message?: string; + details?: Record; + suggestion?: string; +}> { + const contentType = response.headers.get('content-type') || ''; + const cloned = response.clone(); + + try { + if (contentType.includes('application/json')) { + const body = await cloned.json(); + if (body && typeof body === 'object') { + return { + message: typeof body.message === 'string' ? body.message + : typeof body.error === 'string' ? body.error + : typeof body.error?.message === 'string' ? body.error.message + : undefined, + details: body.details || body.errors || undefined, + suggestion: typeof body.suggestion === 'string' ? body.suggestion : undefined, + }; + } + } + } catch { + // JSON parse failed, try as text + } + + try { + const text = await cloned.text(); + if (text) { + return { message: text }; + } + } catch { + // Text read failed + } + + return {}; +} + +/** + * Returns a user-facing suggestion based on the HTTP status code. + */ +function getDefaultSuggestion(status: number): string | undefined { + if (status === 401) return 'Your session may have expired. Please try logging in again.'; + if (status === 403) return 'You do not have permission to perform this action.'; + if (status === 404) return 'The requested resource was not found.'; + if (status === 429) return 'Too many requests. Please wait a moment and try again.'; + if (status >= 500) return 'The server encountered an error. Please try again later.'; + return undefined; +} + function buildUrl(path: string, params?: QueryParams): string { const baseUrl = `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`; if (!params) return baseUrl; diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_diagnostic_metadata.py b/tests/test_diagnostic_metadata.py new file mode 100644 index 00000000..e769c874 --- /dev/null +++ b/tests/test_diagnostic_metadata.py @@ -0,0 +1,326 @@ +""" +Tests for diagnostic report generation and metadata shape. + +These tests validate that: +- Successful report metadata includes commit id, module summaries, and diagnostic_logd path. +- logd generation failure populates diagnostic_logd_error without claiming a valid archive. +- Chunked or multi-file logd references work correctly. +- Tests are deterministic and avoid requiring external toolchains or network access. + +Run: python3 -m pytest tests/test_diagnostic_metadata.py -v +""" + +import sys +import tempfile +from pathlib import Path + +import pytest + +# Add project root to path so we can import build.py helpers +ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(ROOT)) + + +# --------------------------------------------------------------------------- +# Successful report metadata +# --------------------------------------------------------------------------- + +def test_report_contains_commit_id(): + """build_diagnostic_report includes the commit id.""" + from build import build_diagnostic_report + + report = build_diagnostic_report( + results=[("module-a", True, 1.0, "ok", None)], + commit_id="abcd1234", + logd_relpaths=["diagnostic/build-abcd1234.logd"], + ) + assert report["commit"] == "abcd1234" + + +def test_report_contains_module_summaries(): + """build_diagnostic_report returns module summaries with name, status, elapsed.""" + from build import build_diagnostic_report + + report = build_diagnostic_report( + results=[("module-a", True, 1.5, "build output", "target/debug/module-a")], + commit_id="abcd1234", + logd_relpaths=["diagnostic/build-abcd1234.logd"], + ) + + modules = report["modules"] + assert len(modules) == 1 + mod = modules[0] + assert mod["name"] == "module-a" + assert mod["status"] == "PASS" + assert mod["elapsed_seconds"] == 1.5 + assert mod["artifact"] == "target/debug/module-a" + + +def test_report_contains_diagnostic_logd_path(): + """build_diagnostic_report includes the diagnostic_logd path in JSON.""" + from build import build_diagnostic_report + + report = build_diagnostic_report( + results=[("module-a", True, 1.0, "ok", None)], + commit_id="abcd1234", + logd_relpaths=["diagnostic/build-abcd1234.logd"], + ) + + assert report["diagnostic_logd"] is not None + assert report["diagnostic_logd"] == "diagnostic/build-abcd1234.logd" + + +def test_report_tracks_pass_fail_counts(): + """build_diagnostic_report correctly counts passed and failed modules.""" + from build import build_diagnostic_report + + results = [ + ("module-a", True, 0.5, "ok", None), + ("module-b", False, 1.0, "error", None), + ("module-c", True, 2.0, "ok", None), + ] + report = build_diagnostic_report( + results=results, + commit_id="abcd1234", + logd_relpaths=["diagnostic/build-abcd1234.logd"], + ) + + assert report["total_modules"] == 3 + assert report["passed"] == 2 + assert report["failed"] == 1 + + +def test_report_modules_preserve_order(): + """Module list preserves the original insertion order.""" + from build import build_diagnostic_report + + results = [ + ("backend", True, 10.0, "ok", "debug/backend"), + ("frontend", False, 5.0, "npm error", None), + ] + report = build_diagnostic_report( + results=results, + commit_id="abcd1234", + logd_relpaths=["diagnostic/build-abcd1234.logd"], + ) + + names = [m["name"] for m in report["modules"]] + assert names == ["backend", "frontend"] + + +def test_report_generated_at_is_iso_format(): + """generated_at field is ISO 8601 formatted timestamp.""" + from build import build_diagnostic_report + + report = build_diagnostic_report( + results=[("module-a", True, 1.0, "ok", None)], + commit_id="abcd1234", + logd_relpaths=["diagnostic/build-abcd1234.logd"], + ) + + ts = report["generated_at"] + assert "T" in ts, f"Expected ISO 8601 timestamp (with T), got {ts}" + assert ts.endswith("+00:00") or ts.endswith("Z") or "+" in ts[19:], ( + f"Expected timezone info in timestamp: {ts}" + ) + + +# --------------------------------------------------------------------------- +# logd generation failure — populated error without valid archive +# --------------------------------------------------------------------------- + +def test_logd_error_populated_when_encryption_fails(): + """When logd creation fails, diagnostic_logd is None and diagnostic_logd_error has the reason.""" + from build import build_diagnostic_report + + error_msg = "encryptly binary not found (detected macos-arm64)" + report = build_diagnostic_report( + results=[("module-a", False, 0.5, "error", None)], + commit_id="abcd1234", + logd_error=error_msg, + ) + + assert report["diagnostic_logd"] is None, ( + "diagnostic_logd must be None when logd creation fails" + ) + assert report["diagnostic_logd_error"] == error_msg + + +def test_logd_error_without_logd_ref_does_not_claim_valid_archive(): + """When logd_error is set and logd_relpaths is None, no valid archive is claimed.""" + from build import build_diagnostic_report + + report = build_diagnostic_report( + results=[("module-a", False, 0.5, "error", None)], + commit_id="abcd1234", + logd_error="encryptly pack failed", + ) + + # Must not claim a valid .logd reference + assert report["diagnostic_logd"] is None + # Must not claim a password (no archive was created) + assert report.get("password") is None + # Must not claim a decrypt command (no archive to decrypt) + assert report.get("decrypt_command") is None + + +def test_logd_error_sets_blocker_message(): + """When logd_error is set, message_blocker should be populated.""" + from build import build_diagnostic_report + + report = build_diagnostic_report( + results=[("module-a", False, 0.5, "error", None)], + commit_id="abcd1234", + logd_error="encryptly binary not found", + message_blocker="You need to fix your environment so encryptly runs before building.", + ) + + assert report["message_blocker"] is not None + assert "encryptly" in report["message_blocker"] + + +# --------------------------------------------------------------------------- +# Chunked / multi-file logd references +# --------------------------------------------------------------------------- + +def test_single_logd_ref_is_string(): + """A single logd artifact is stored as a string, not a list.""" + from build import build_diagnostic_report + + report = build_diagnostic_report( + results=[("module-a", True, 1.0, "ok", None)], + commit_id="abcd1234", + logd_relpaths=["diagnostic/build-abcd1234.logd"], + ) + + assert isinstance(report["diagnostic_logd"], str) + assert not isinstance(report["diagnostic_logd"], list) + + +def test_chunked_logd_ref_is_list(): + """Multiple chunked logd artifacts are stored as a list of strings.""" + from build import build_diagnostic_report + + chunks = [ + "diagnostic/build-abcd1234-part001.logd", + "diagnostic/build-abcd1234-part002.logd", + ] + report = build_diagnostic_report( + results=[("module-a", True, 1.0, "ok", None)], + commit_id="abcd1234", + logd_relpaths=chunks, + chunked=True, + ) + + assert isinstance(report["diagnostic_logd"], list) + assert len(report["diagnostic_logd"]) == 2 + assert report["chunked"] is True + + +def test_chunked_report_has_chunk_size(): + """Chunked report includes the chunk_size_bytes field.""" + from build import build_diagnostic_report, DIAGNOSTIC_CHUNK_SIZE + + chunks = [ + "diagnostic/build-abcd1234-part001.logd", + "diagnostic/build-abcd1234-part002.logd", + ] + report = build_diagnostic_report( + results=[("module-a", True, 1.0, "ok", None)], + commit_id="abcd1234", + logd_relpaths=chunks, + chunked=True, + ) + + assert report["chunk_size_bytes"] == DIAGNOSTIC_CHUNK_SIZE + + +def test_chunked_report_no_chunk_size_when_not_chunked(): + """When not chunked, chunk_size_bytes should be None.""" + from build import build_diagnostic_report + + report = build_diagnostic_report( + results=[("module-a", True, 1.0, "ok", None)], + commit_id="abcd1234", + logd_relpaths=["diagnostic/build-abcd1234.logd"], + chunked=False, + ) + + assert report["chunk_size_bytes"] is None + + +def test_chunked_refs_are_forward_slash_paths(): + """Chunked logd paths use forward slashes (even on Windows).""" + from build import build_diagnostic_report + + chunks = [ + "diagnostic/build-abcd1234-part001.logd", + "diagnostic/build-abcd1234-part002.logd", + ] + report = build_diagnostic_report( + results=[("module-a", True, 1.0, "ok", None)], + commit_id="abcd1234", + logd_relpaths=chunks, + chunked=True, + ) + + for ref in report["diagnostic_logd"]: + assert "\\" not in ref, f"Backslash found in logd ref: {ref}" + assert ref.startswith("diagnostic/"), f"Path doesn't start with diagnostic/: {ref}" + + +# --------------------------------------------------------------------------- +# Decrypt command generation +# --------------------------------------------------------------------------- + +def test_decrypt_command_generated_with_password(): + """When password is provided, decrypt_command is populated.""" + from build import build_diagnostic_report + + report = build_diagnostic_report( + results=[("module-a", True, 1.0, "ok", None)], + commit_id="abcd1234", + logd_relpaths=["diagnostic/build-abcd1234.logd"], + password="test-password-123", + ) + + cmd = report["decrypt_command"] + assert cmd is not None + assert "encryptly unpack" in cmd + assert "test-password-123" in cmd + + +def test_decrypt_command_none_without_password(): + """When password is None, decrypt_command is None.""" + from build import build_diagnostic_report + + report = build_diagnostic_report( + results=[("module-a", True, 1.0, "ok", None)], + commit_id="abcd1234", + logd_relpaths=["diagnostic/build-abcd1234.logd"], + ) + + assert report.get("decrypt_command") is None + + +# --------------------------------------------------------------------------- +# Determinism and stability +# --------------------------------------------------------------------------- + +def test_commit_id_format(): + """current_commit_id() returns an 8-char hex string (or fallback 00000000).""" + from build import current_commit_id + + commit = current_commit_id() + assert isinstance(commit, str) + assert len(commit) == 8 + assert all(c in "0123456789abcdef" for c in commit) + + +def test_diagnostic_path_pattern(): + """diagnostic_paths_for_commit() returns paths matching build-PATTERN.logd/json.""" + from build import diagnostic_paths_for_commit + + logd, meta, commit_id = diagnostic_paths_for_commit() + assert logd.name == f"build-{commit_id}.logd" + assert meta.name == f"build-{commit_id}.json" diff --git a/tools/encryptly/linux-arm64/encryptly b/tools/encryptly/linux-arm64/encryptly index 9826108e..cdc84074 100755 Binary files a/tools/encryptly/linux-arm64/encryptly and b/tools/encryptly/linux-arm64/encryptly differ diff --git a/tools/encryptly/linux-x64/encryptly b/tools/encryptly/linux-x64/encryptly index d69f0bb0..60ac61d1 100755 Binary files a/tools/encryptly/linux-x64/encryptly and b/tools/encryptly/linux-x64/encryptly differ diff --git a/tools/encryptly/macos-arm64/encryptly b/tools/encryptly/macos-arm64/encryptly index f7d6e4fe..b07cea65 100755 Binary files a/tools/encryptly/macos-arm64/encryptly and b/tools/encryptly/macos-arm64/encryptly differ diff --git a/tools/encryptly/macos-x64/encryptly b/tools/encryptly/macos-x64/encryptly index dcbd8286..aedb151d 100755 Binary files a/tools/encryptly/macos-x64/encryptly and b/tools/encryptly/macos-x64/encryptly differ diff --git a/tools/encryptly/windows-arm64/encryptly.exe b/tools/encryptly/windows-arm64/encryptly.exe index 3f0818b8..9ab1a964 100644 Binary files a/tools/encryptly/windows-arm64/encryptly.exe and b/tools/encryptly/windows-arm64/encryptly.exe differ diff --git a/tools/encryptly/windows-x64/encryptly.exe b/tools/encryptly/windows-x64/encryptly.exe index d3d2b7f0..dd5abb13 100644 Binary files a/tools/encryptly/windows-x64/encryptly.exe and b/tools/encryptly/windows-x64/encryptly.exe differ