diff --git a/build.py b/build.py index 0116f883..a844dd7a 100644 --- a/build.py +++ b/build.py @@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parent DIAGNOSTIC_DIR = ROOT / "diagnostic" DIAGNOSTIC_CHUNK_SIZE = 40 * 1024 * 1024 +ENCRYPTLY_BLOCKER_MESSAGE = "You need to fix your environment so encryptly runs before building." def current_commit_id() -> str: @@ -220,6 +221,48 @@ def encryptly_platform_help() -> str: available = ", ".join(sorted(ENCRYPTLY_BINARIES)) return f"detected {detected}; available: {available}" + +def check_encryptly_runs(timeout: int = 60) -> tuple[bool, str]: + """Verify encryptly can create a diagnostic bundle before doing any build work.""" + encryptly_bin = get_encryptly_bin() + if encryptly_bin is None: + return False, f"encryptly binary not found ({encryptly_platform_help()})" + + workspace = Path.home() / ".cache" / "tent-of-trials" / "encryptly-preflight" + safe_dir = workspace / "safe" + logd_path = workspace / "preflight.logd" + try: + shutil.rmtree(workspace, ignore_errors=True) + safe_dir.mkdir(parents=True, exist_ok=True) + (safe_dir / "preflight.txt").write_text("encryptly preflight\n", encoding="utf-8") + result = subprocess.run( + [ + str(encryptly_bin), + "pack", + str(logd_path), + "--include", + str(workspace), + "--max-file-size", + "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 not logd_path.exists(): + return False, "encryptly preflight completed without creating a .logd" + return True, "encryptly preflight passed" + except subprocess.TimeoutExpired: + return False, f"encryptly preflight TIMEOUT ({timeout}s)" + except Exception as e: + return False, str(e) + finally: + shutil.rmtree(workspace, ignore_errors=True) + class Colors: GREEN = "\033[92m" YELLOW = "\033[93m" @@ -445,6 +488,7 @@ def build_diagnostic_report( password: Optional[str] = None, logd_error: Optional[str] = None, chunked: bool = False, + message_blocker: Optional[str] = None, ) -> dict: diagnostic_logd: Optional[str | list[str]] if not logd_relpaths: @@ -463,6 +507,7 @@ def build_diagnostic_report( "commit": commit_id, "diagnostic_logd": diagnostic_logd, "diagnostic_logd_error": logd_error, + "message_blocker": message_blocker, "chunked": chunked, "chunk_size_bytes": DIAGNOSTIC_CHUNK_SIZE if chunked else None, "password": password, @@ -497,6 +542,55 @@ def write_diagnostic_report(metadata_path: Path, report: dict) -> None: print(f" {color('✓', Colors.GREEN)} {metadata_path.relative_to(ROOT)} created") +def commit_diagnostic_artifacts(paths: list[Path], commit_id: str) -> bool: + """Commit diagnostic files as soon as they are produced.""" + existing = [path for path in paths if path.exists()] + if not existing: + print(f" {color('✗', Colors.RED)} No diagnostic artifacts found to commit") + return False + + relpaths = [str(path.relative_to(ROOT)) for path in existing] + status = subprocess.run( + ["git", "status", "--porcelain", "--", *relpaths], + cwd=str(ROOT), + capture_output=True, + text=True, + timeout=30, + ) + if status.returncode != 0: + print(f" {color('✗', Colors.RED)} Could not inspect diagnostic git status: {status.stderr.strip()}") + return False + if not status.stdout.strip(): + print(f" {color('✓', Colors.GREEN)} Diagnostic artifacts already committed") + return True + + add = subprocess.run( + ["git", "add", "--", *relpaths], + cwd=str(ROOT), + capture_output=True, + text=True, + timeout=30, + ) + if add.returncode != 0: + print(f" {color('✗', Colors.RED)} Could not stage diagnostic artifacts: {add.stderr.strip()}") + return False + + commit = subprocess.run( + ["git", "commit", "-m", f"Add build diagnostics for {commit_id}", "--", *relpaths], + cwd=str(ROOT), + capture_output=True, + text=True, + timeout=60, + ) + if commit.returncode != 0: + output = commit.stderr.strip() or commit.stdout.strip() + print(f" {color('✗', Colors.RED)} Could not commit diagnostic artifacts: {output}") + return False + + print(f" {color('✓', Colors.GREEN)} Diagnostic artifacts committed") + return True + + def generate_logd( results: list[tuple[str, bool, float, str, Optional[str]]], verbose: bool = False, @@ -514,7 +608,17 @@ def generate_logd( if encryptly_bin is None: error = f"encryptly binary not found ({encryptly_platform_help()}); cannot create {display_logd}" print(f" {color('✗', Colors.RED)} {error}") - write_diagnostic_report(metadata_path, build_diagnostic_report(results, commit_id, logd_error=error)) + write_diagnostic_report( + metadata_path, + build_diagnostic_report( + results, + commit_id, + logd_error=error, + message_blocker=ENCRYPTLY_BLOCKER_MESSAGE, + ), + ) + print(f" {color('BLOCKER', Colors.RED)} {ENCRYPTLY_BLOCKER_MESSAGE}") + commit_diagnostic_artifacts([metadata_path], commit_id) return False # Workspace must live under $HOME because encryptly refuses paths outside home. @@ -569,7 +673,7 @@ def generate_logd( "--include", str(workspace), "--max-file-size", - "10000", + "61440", ], cwd=str(ROOT), capture_output=True, @@ -586,8 +690,15 @@ def generate_logd( logd_path.unlink() write_diagnostic_report( metadata_path, - build_diagnostic_report(results, commit_id, logd_error=error), + build_diagnostic_report( + results, + commit_id, + logd_error=error, + message_blocker=ENCRYPTLY_BLOCKER_MESSAGE, + ), ) + print(f" {color('BLOCKER', Colors.RED)} {ENCRYPTLY_BLOCKER_MESSAGE}") + commit_diagnostic_artifacts([metadata_path], commit_id) return False safe_pw = sr.stdout.strip() @@ -616,6 +727,9 @@ def generate_logd( f" {color('✓', Colors.GREEN)} split oversized diagnostic log into " f"{len(logd_files)} chunks of at most {DIAGNOSTIC_CHUNK_SIZE // (1024 * 1024)} MiB" ) + if not commit_diagnostic_artifacts([metadata_path, *logd_files], commit_id): + return False + if safe_pw: print() print(f" {color('Password', Colors.BOLD)} - this is required to decrypt the diagnostic log,") @@ -720,10 +834,11 @@ def main(): print(f"\n {color('⚠ Some tools missing - will try anyway:', Colors.YELLOW)}") for m in missing: print(f" {m}") - print(f" {color('Not all modules will build. That\'s fine.', Colors.GRAY)}") + + msg = "Not all modules will build. That's fine." + print(f" {color(msg, Colors.GRAY)}") else: print(f" {color('✓ All prerequisites found', Colors.GREEN)}") - if args.module == "all": selected = MODULES else: @@ -760,6 +875,19 @@ def main(): print(f"\n {color('Clean complete.', Colors.GREEN)}") return 0 + print(f"\n {color('Checking encryptly diagnostics...', Colors.GRAY)}") + encryptly_start = time.time() + encryptly_ok, encryptly_message = check_encryptly_runs() + if not encryptly_ok: + elapsed = time.time() - encryptly_start + blocker = f"{ENCRYPTLY_BLOCKER_MESSAGE} {encryptly_message}" + print(f" {color('✗ encryptly cannot run', Colors.RED)}") + print(f" {color('BLOCKER:', Colors.RED)} {blocker}") + results = [("encryptly-preflight", False, elapsed, blocker, None)] + generate_logd(results, args.verbose) + return 1 + print(f" {color('✓ encryptly runs', Colors.GREEN)}") + print(f"\n {color(f'Building {len(selected)} module(s) | release={args.release}', Colors.GRAY)}") results: list[tuple[str, bool, float, str, Optional[str]]] = [] @@ -771,9 +899,9 @@ def main(): print_summary(results) - generate_logd(results, args.verbose) + diagnostics_ok = generate_logd(results, args.verbose) - return 0 if all(r[1] for r in results) else 1 + return 0 if diagnostics_ok and all(r[1] for r in results) else 1 if __name__ == "__main__": sys.exit(main()) diff --git a/diagnostic/build-ec4b07fc.json b/diagnostic/build-ec4b07fc.json new file mode 100644 index 00000000..84db853f --- /dev/null +++ b/diagnostic/build-ec4b07fc.json @@ -0,0 +1,24 @@ +{ + "generated_at": "2026-07-09T09:32:48.162927+00:00", + "commit": "ec4b07fc", + "diagnostic_logd": null, + "diagnostic_logd_error": "e6702b6c62d1f9f8721f", + "message_blocker": "You need to fix your environment so encryptly runs before building.", + "chunked": false, + "chunk_size_bytes": null, + "password": null, + "decrypt_command": null, + "total_modules": 1, + "passed": 0, + "failed": 1, + "modules": [ + { + "name": "encryptly-preflight", + "status": "FAIL", + "elapsed_seconds": 6.957, + "artifact": null, + "output": "You need to fix your environment so encryptly runs before building. encryptly preflight completed without creating a .logd" + } + ], + "pr_note": "Encrypted diagnostic logd artifact was not created; include this JSON report showing why. The encrypted .logd is the required diagnostic content for PR review; this JSON file is metadata. Maintainers may ask you to remove these diagnostic artifacts before merging." +} 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/market/ws/hub_test.go b/market/ws/hub_test.go new file mode 100644 index 00000000..97a7f85d --- /dev/null +++ b/market/ws/hub_test.go @@ -0,0 +1,260 @@ +package ws + +import ( + "testing" + "time" + + "go.uber.org/zap" +) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func newTestHub() *Hub { + return NewHub(zap.NewNop()) +} + +func newTestClient(hub *Hub, bufSize int) *Client { + return &Client{ + hub: hub, + send: make(chan []byte, bufSize), + } +} + +func waitForHubState(t *testing.T, hub *Hub, expected int, timeout time.Duration) []*Client { + t.Helper() + deadline := time.After(timeout) + for { + hub.mu.RLock() + count := len(hub.clients) + if count == expected { + // Build a snapshot of clients + snapshot := make([]*Client, 0, count) + for c := range hub.clients { + snapshot = append(snapshot, c) + } + hub.mu.RUnlock() + return snapshot + } + hub.mu.RUnlock() + select { + case <-deadline: + t.Fatalf("timed out waiting for hub to have %d clients, got %d", expected, count) + return nil + case <-time.After(5 * time.Millisecond): + } + } +} + +// --------------------------------------------------------------------------- +// Registration +// --------------------------------------------------------------------------- + +func TestHub_RegisterClient(t *testing.T) { + hub := newTestHub() + go hub.Run() + + client := newTestClient(hub, 256) + hub.register <- client + + snapshot := waitForHubState(t, hub, 1, time.Second) + if snapshot[0] != client { + t.Error("registered client not found in hub") + } +} + +func TestHub_RegisterMultipleClients(t *testing.T) { + hub := newTestHub() + go hub.Run() + + const count = 5 + for i := 0; i < count; i++ { + client := newTestClient(hub, 256) + hub.register <- client + } + + waitForHubState(t, hub, count, time.Second) +} + +// --------------------------------------------------------------------------- +// Unregister +// --------------------------------------------------------------------------- + +func TestHub_UnregisterClient(t *testing.T) { + hub := newTestHub() + go hub.Run() + + client := newTestClient(hub, 256) + hub.register <- client + waitForHubState(t, hub, 1, time.Second) + + hub.unregister <- client + waitForHubState(t, hub, 0, time.Second) +} + +func TestHub_UnregisterClosesSendChannel(t *testing.T) { + hub := newTestHub() + go hub.Run() + + client := newTestClient(hub, 256) + hub.register <- client + waitForHubState(t, hub, 1, time.Second) + + hub.unregister <- client + waitForHubState(t, hub, 0, time.Second) + + // Send channel should be closed after unregister + _, ok := <-client.send + if ok { + t.Error("send channel should be closed after unregister") + } +} + +func TestHub_UnregisterTwice_NoPanic(t *testing.T) { + hub := newTestHub() + go hub.Run() + + client := newTestClient(hub, 256) + hub.register <- client + waitForHubState(t, hub, 1, time.Second) + + hub.unregister <- client + waitForHubState(t, hub, 0, time.Second) + + // Second unregister should not panic + hub.unregister <- client + // Give hub time to process — it should just be a no-op + time.Sleep(50 * time.Millisecond) + hub.mu.RLock() + count := len(hub.clients) + hub.mu.RUnlock() + if count != 0 { + t.Errorf("expected 0 clients after second unregister, got %d", count) + } +} + +// --------------------------------------------------------------------------- +// Broadcast +// --------------------------------------------------------------------------- + +func TestHub_BroadcastDeliversToActiveClients(t *testing.T) { + hub := newTestHub() + go hub.Run() + + // Register 3 clients + clients := make([]*Client, 3) + for i := range clients { + clients[i] = newTestClient(hub, 256) + hub.register <- clients[i] + } + waitForHubState(t, hub, 3, time.Second) + + // Broadcast a message + msg := []byte("hello") + hub.broadcast <- msg + + // All clients should receive the message + for i, c := range clients { + select { + case received := <-c.send: + if string(received) != string(msg) { + t.Errorf("client %d: expected %q, got %q", i, string(msg), string(received)) + } + case <-time.After(time.Second): + t.Errorf("client %d: timed out waiting for broadcast message", i) + } + } +} + +func TestHub_BroadcastDoesNotDeliverToUnregisteredClients(t *testing.T) { + hub := newTestHub() + go hub.Run() + + client := newTestClient(hub, 256) + hub.register <- client + waitForHubState(t, hub, 1, time.Second) + + hub.unregister <- client + waitForHubState(t, hub, 0, time.Second) + + // After unregister, the client is no longer in the hub (count = 0). + // Broadcast should not panic or side-effect. + hub.broadcast <- []byte("should be safe") + + // Verify the hub state is still clean after broadcast + waitForHubState(t, hub, 0, 2*time.Second) +} + +// --------------------------------------------------------------------------- +// Slow client removal (the locking race) +// --------------------------------------------------------------------------- + +func TestHub_SlowClientRemovedOnBroadcast(t *testing.T) { + hub := newTestHub() + go hub.Run() + + // Create a client with a tiny send buffer (1) + slowClient := newTestClient(hub, 1) + hub.register <- slowClient + + // Create a normal client + normalClient := newTestClient(hub, 256) + hub.register <- normalClient + + waitForHubState(t, hub, 2, time.Second) + + // Fill the slow client's send buffer + slowClient.send <- []byte("fill") + + // Broadcast should cause the slow client to be removed + hub.broadcast <- []byte("test message") + + // Normal client should still receive + select { + case <-normalClient.send: + // OK + case <-time.After(time.Second): + t.Error("normal client did not receive broadcast") + } + + // Slow client should have been removed + waitForHubState(t, hub, 1, time.Second) + + hub.mu.RLock() + found := false + for c := range hub.clients { + if c == slowClient { + found = true + break + } + } + hub.mu.RUnlock() + if found { + t.Error("slow client should have been removed from hub after broadcast") + } +} + +func TestHub_SlowClientSendChannelClosed(t *testing.T) { + hub := newTestHub() + go hub.Run() + + client := newTestClient(hub, 1) + hub.register <- client + waitForHubState(t, hub, 1, time.Second) + + // Fill buffer and broadcast to trigger removal + client.send <- []byte("fill") + hub.broadcast <- []byte("trigger removal") + + waitForHubState(t, hub, 0, time.Second) + + // Drain any remaining buffered messages first + <-client.send // drain "fill" + + // Now reading from closed (and drained) channel should give ok=false + _, ok := <-client.send + if ok { + t.Error("slow client's send channel should be closed after forced removal") + } +} diff --git a/market/ws/server.go b/market/ws/server.go index b87cea9f..5483c49b 100644 --- a/market/ws/server.go +++ b/market/ws/server.go @@ -80,17 +80,27 @@ func (h *Hub) Run() { zap.Int("total", len(h.clients)), ) - case message := <-h.broadcast: - h.mu.RLock() - for client := range h.clients { - select { - case client.send <- message: - default: - close(client.send) - delete(h.clients, client) - } + case message := <-h.broadcast: + h.mu.RLock() + var slow []*Client + for client := range h.clients { + select { + case client.send <- message: + default: + close(client.send) + slow = append(slow, client) + } + } + h.mu.RUnlock() + // Remove slow clients under a write lock to avoid mutating the + // map while readers may be iterating it. + if len(slow) > 0 { + h.mu.Lock() + for _, c := range slow { + delete(h.clients, c) } - h.mu.RUnlock() + h.mu.Unlock() + } } } } 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 16dab211..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 2e1f5ff3..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 32fa1516..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 new file mode 100755 index 00000000..aedb151d Binary files /dev/null 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 6e415571..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 c414bb27..dd5abb13 100644 Binary files a/tools/encryptly/windows-x64/encryptly.exe and b/tools/encryptly/windows-x64/encryptly.exe differ