Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cda3a01e00 |
+12
-10
@@ -145,10 +145,10 @@ jobs:
|
||||
# the diagnostic step with its own continue-on-error: true (line 203).
|
||||
# Flip confirmed by CI / Platform (Go) status = success on main HEAD 363905d3.
|
||||
continue-on-error: false
|
||||
# Job-level ceiling. The go test step below runs with a per-step 10m timeout;
|
||||
# this cap catches any step that leaks past that. Set well above 10m so
|
||||
# Job-level ceiling. The go test step below runs with a per-step 30m timeout;
|
||||
# this cap catches any step that leaks past that. Set well above 30m so
|
||||
# the per-step timeout is the active constraint.
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 35
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workspace-server
|
||||
@@ -176,12 +176,14 @@ jobs:
|
||||
name: Run golangci-lint
|
||||
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
|
||||
- if: always()
|
||||
name: Diagnostic — per-package verbose 60s
|
||||
name: Diagnostic — per-package verbose (300s timeout)
|
||||
run: |
|
||||
set +e
|
||||
go test -race -v -timeout 60s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
|
||||
# 300s allows handlers + pendinguploads packages to complete on cold
|
||||
# runners with -race instrumentation (~60-120s each vs ~14s non-race).
|
||||
go test -race -v -timeout 300s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
|
||||
handlers_exit=$?
|
||||
go test -race -v -timeout 60s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
|
||||
go test -race -v -timeout 300s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
|
||||
pu_exit=$?
|
||||
echo "::group::handlers exit=$handlers_exit (last 100 lines)"
|
||||
tail -100 /tmp/test-handlers.log
|
||||
@@ -194,10 +196,10 @@ jobs:
|
||||
- if: always()
|
||||
name: Run tests with race detection and coverage
|
||||
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the
|
||||
# full ./... suite with race detection + coverage. A 10m per-step timeout
|
||||
# lets the suite complete on cold cache (~5-7m) while failing cleanly
|
||||
# instead of OOM-killing. The job-level timeout (15m) is a backstop.
|
||||
run: go test -race -timeout 10m -coverprofile=coverage.out ./...
|
||||
# full ./... suite with race detection + coverage. A 30m per-step timeout
|
||||
# lets the suite complete on cold cache (~13-25m) while failing cleanly
|
||||
# instead of OOM-killing. The job-level timeout (35m) is a backstop.
|
||||
run: go test -race -timeout 30m -coverprofile=coverage.out ./...
|
||||
|
||||
- if: always()
|
||||
name: Per-file coverage report
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
/** Org-wide broadcast banner.
|
||||
*
|
||||
* Rendered at the top of the canvas (below the toolbar) whenever the store
|
||||
* holds one or more unread BROADCAST_MESSAGE entries. Each entry shows:
|
||||
* - sender name (workspace that issued the broadcast)
|
||||
* - the message text
|
||||
* - a dismiss button
|
||||
*
|
||||
* Dismissing an entry removes it from the store via consumeBroadcastMessages.
|
||||
* The dismissed state is intentionally ephemeral — dismissed broadcasts reappear
|
||||
* on page refresh since they are not persisted server-side; this is intentional
|
||||
* (the platform's activity log already provides the audit trail).
|
||||
*/
|
||||
export function BroadcastBanner() {
|
||||
const broadcastMessages = useCanvasStore((s) => s.broadcastMessages);
|
||||
const consumeBroadcastMessages = useCanvasStore((s) => s.consumeBroadcastMessages);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
void consumeBroadcastMessages();
|
||||
}, [consumeBroadcastMessages]);
|
||||
|
||||
if (broadcastMessages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-16 left-1/2 -translate-x-1/2 z-30 flex flex-col gap-2 items-center w-full max-w-xl px-4 pointer-events-none">
|
||||
{broadcastMessages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="pointer-events-auto w-full bg-blue-950/80 backdrop-blur-md border border-blue-700/50 rounded-xl px-5 py-3 shadow-2xl shadow-black/40 animate-in slide-in-from-top duration-300"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Megaphone icon */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="w-7 h-7 rounded-lg bg-blue-900/50 flex items-center justify-center shrink-0 mt-0.5"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-blue-300"
|
||||
>
|
||||
<path d="M3 11l18-5v12L3 13v-2z" />
|
||||
<path d="M11.6 16.8a3 3 0 1 1-5.8-1.6" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-blue-300 font-semibold">
|
||||
Broadcast from{" "}
|
||||
<span className="text-blue-100">{msg.sender}</span>
|
||||
</div>
|
||||
<div className="text-sm text-blue-50 mt-0.5 leading-snug break-words">
|
||||
{msg.message}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss broadcast"
|
||||
className="shrink-0 w-6 h-6 rounded text-blue-400 hover:text-blue-200 hover:bg-blue-800/50 flex items-center justify-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-1 focus-visible:ring-offset-blue-950"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import { CreateWorkspaceButton } from "./CreateWorkspaceDialog";
|
||||
import { ContextMenu } from "./ContextMenu";
|
||||
import { TemplatePalette } from "./TemplatePalette";
|
||||
import { ApprovalBanner } from "./ApprovalBanner";
|
||||
import { BroadcastBanner } from "./BroadcastBanner";
|
||||
import { BundleDropZone } from "./BundleDropZone";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { OnboardingWizard } from "./OnboardingWizard";
|
||||
@@ -368,7 +367,6 @@ function CanvasInner() {
|
||||
<OnboardingWizard />
|
||||
<Toolbar />
|
||||
<ApprovalBanner />
|
||||
<BroadcastBanner />
|
||||
<BundleDropZone />
|
||||
<TemplatePalette />
|
||||
<SidePanel />
|
||||
|
||||
@@ -73,8 +73,6 @@ const mockStoreState = {
|
||||
clearSelection: vi.fn(),
|
||||
toggleNodeSelection: vi.fn(),
|
||||
deletingIds: new Set<string>(),
|
||||
broadcastMessages: [],
|
||||
consumeBroadcastMessages: vi.fn(() => []),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
@@ -102,7 +100,6 @@ vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
|
||||
vi.mock("../TemplatePalette", () => ({ TemplatePalette: () => null }));
|
||||
vi.mock("../OnboardingWizard", () => ({ OnboardingWizard: () => null }));
|
||||
vi.mock("../ApprovalBanner", () => ({ ApprovalBanner: () => null }));
|
||||
vi.mock("../BroadcastBanner", () => ({ BroadcastBanner: () => null }));
|
||||
vi.mock("../BundleDropZone", () => ({ BundleDropZone: () => null }));
|
||||
vi.mock("../CreateWorkspaceDialog", () => ({ CreateWorkspaceButton: () => null }));
|
||||
vi.mock("../settings", () => ({
|
||||
|
||||
@@ -91,8 +91,6 @@ const mockStoreState = {
|
||||
// an empty Set mirrors the idle canvas and doesn't interact with
|
||||
// any pan/fit behaviour under test here.
|
||||
deletingIds: new Set<string>(),
|
||||
broadcastMessages: [],
|
||||
consumeBroadcastMessages: vi.fn(() => []),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
@@ -119,7 +117,6 @@ vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
|
||||
vi.mock("../TemplatePalette", () => ({ TemplatePalette: () => null }));
|
||||
vi.mock("../OnboardingWizard", () => ({ OnboardingWizard: () => null }));
|
||||
vi.mock("../ApprovalBanner", () => ({ ApprovalBanner: () => null }));
|
||||
vi.mock("../BroadcastBanner", () => ({ BroadcastBanner: () => null }));
|
||||
vi.mock("../BundleDropZone", () => ({ BundleDropZone: () => null }));
|
||||
vi.mock("../CreateWorkspaceDialog", () => ({ CreateWorkspaceButton: () => null }));
|
||||
vi.mock("../settings", () => ({
|
||||
|
||||
@@ -53,10 +53,9 @@ function makeStore(
|
||||
edges: Edge[] = [],
|
||||
selectedNodeId: string | null = null,
|
||||
agentMessages: Record<string, Array<{ id: string; content: string; timestamp: string }>> = {},
|
||||
liveAnnouncement = "",
|
||||
broadcastMessages: Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }> = []
|
||||
liveAnnouncement = ""
|
||||
) {
|
||||
const state = { nodes, edges, selectedNodeId, agentMessages, liveAnnouncement, broadcastMessages };
|
||||
const state = { nodes, edges, selectedNodeId, agentMessages, liveAnnouncement };
|
||||
const get = () => state;
|
||||
const set = vi.fn((partial: Record<string, unknown>) => {
|
||||
Object.assign(state, partial);
|
||||
@@ -1014,149 +1013,3 @@ describe("handleCanvasEvent – liveAnnouncement", () => {
|
||||
expect(state.liveAnnouncement ?? "").toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BROADCAST_MESSAGE
|
||||
//
|
||||
// Verifies that incoming org-wide broadcast WebSocket events are captured
|
||||
// in the store's broadcastMessages array and announced via liveAnnouncement
|
||||
// for screen readers. The Go platform already HTML-escaped the content at
|
||||
// broadcast time (OFFSEC-015 fix), so the handler renders it as-is.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("handleCanvasEvent – BROADCAST_MESSAGE", () => {
|
||||
it("appends a broadcast message to broadcastMessages with correct fields", () => {
|
||||
const { get, set, state } = makeStore();
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: {
|
||||
sender_id: "ws-ops",
|
||||
sender: "Ops Agent",
|
||||
message: "All systems go — deploy in 5 minutes",
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
expect(set).toHaveBeenCalledOnce();
|
||||
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
|
||||
expect(next.broadcastMessages).toHaveLength(1);
|
||||
expect(next.broadcastMessages[0].senderId).toBe("ws-ops");
|
||||
expect(next.broadcastMessages[0].sender).toBe("Ops Agent");
|
||||
expect(next.broadcastMessages[0].message).toBe("All systems go — deploy in 5 minutes");
|
||||
expect(next.broadcastMessages[0].id).toBeTruthy(); // crypto.randomUUID() called
|
||||
expect(next.broadcastMessages[0].timestamp).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sets liveAnnouncement with sender and truncated message", () => {
|
||||
const { get, set } = makeStore();
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: {
|
||||
sender_id: "ws-ops",
|
||||
sender: "Ops Agent",
|
||||
message: "Deploy starting now",
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const next = set.mock.calls[0][0] as { liveAnnouncement: string };
|
||||
expect(next.liveAnnouncement).toBe("Broadcast from Ops Agent: Deploy starting now");
|
||||
});
|
||||
|
||||
it("renders sender name as truncated ID when sender field is absent", () => {
|
||||
const { get, set, state } = makeStore();
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: {
|
||||
sender_id: "ws-ops",
|
||||
message: "Deploy starting now",
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
|
||||
expect(next.broadcastMessages[0].sender).toBe("ws-ops".slice(0, 8)); // fallback: first 8 chars of ID
|
||||
});
|
||||
|
||||
it("is a no-op when message is empty string", () => {
|
||||
const { get, set } = makeStore();
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: { sender_id: "ws-ops", sender: "Ops Agent", message: "" },
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
expect(set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("appends to existing broadcastMessages without replacing them", () => {
|
||||
const { get, set, state } = makeStore([], [], null, {}, "", [
|
||||
{
|
||||
id: "existing-1",
|
||||
senderId: "ws-old",
|
||||
sender: "Old Agent",
|
||||
message: "Previous broadcast",
|
||||
timestamp: "2026-05-14T12:00:00Z",
|
||||
},
|
||||
]);
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: { sender_id: "ws-ops", sender: "Ops Agent", message: "New broadcast" },
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
|
||||
expect(next.broadcastMessages).toHaveLength(2);
|
||||
expect(next.broadcastMessages[0].id).toBe("existing-1");
|
||||
expect(next.broadcastMessages[1].message).toBe("New broadcast");
|
||||
});
|
||||
|
||||
it("handles XSS-like content safely (content is pre-escaped by Go platform)", () => {
|
||||
const { get, set, state } = makeStore();
|
||||
|
||||
// The Go platform applied html.EscapeString before sending, so the handler
|
||||
// receives literal strings, not raw HTML. This test verifies no panic and
|
||||
// correct storage.
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-evil",
|
||||
payload: {
|
||||
sender_id: "ws-evil",
|
||||
sender: "Evil Sender",
|
||||
message: "<script>alert('xss')</script>",
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
|
||||
expect(next.broadcastMessages[0].message).toBe("<script>alert('xss')</script>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,7 +72,6 @@ export function handleCanvasEvent(
|
||||
edges: Edge[];
|
||||
selectedNodeId: string | null;
|
||||
agentMessages: Record<string, Array<{ id: string; content: string; timestamp: string; attachments?: Array<{ name: string; uri: string; mimeType?: string; size?: number }> }>>;
|
||||
broadcastMessages: Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }>;
|
||||
},
|
||||
set: (partial: Record<string, unknown>) => void,
|
||||
): void {
|
||||
@@ -516,34 +515,6 @@ export function handleCanvasEvent(
|
||||
break;
|
||||
}
|
||||
|
||||
case "BROADCAST_MESSAGE": {
|
||||
// An agent workspace sent an org-wide broadcast. Display it as a
|
||||
// dismissible banner so the user is always aware of org-wide signals
|
||||
// even when no workspace is selected. The Go platform already HTML-
|
||||
// escaped the content at broadcast time (OFFSEC-015 fix), so it is
|
||||
// safe to render as innerText equivalent via dangerouslySetInnerHTML
|
||||
// is not needed — just render the string as-is.
|
||||
const senderId = (msg.payload.sender_id as string) ?? "";
|
||||
const sender = (msg.payload.sender as string) ?? senderId.slice(0, 8);
|
||||
const message = (msg.payload.message as string) ?? "";
|
||||
if (!message) break;
|
||||
const { broadcastMessages } = get();
|
||||
set({
|
||||
broadcastMessages: [
|
||||
...broadcastMessages,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
senderId,
|
||||
sender,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
liveAnnouncement: `Broadcast from ${sender}: ${message}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -244,12 +244,6 @@ interface CanvasState {
|
||||
* so the same announcement doesn't re-fire on re-render. */
|
||||
liveAnnouncement: string;
|
||||
setLiveAnnouncement: (msg: string) => void;
|
||||
/** Incoming org-wide broadcast messages received via BROADCAST_MESSAGE
|
||||
* WebSocket events. Consumed by the BroadcastBanner component; each
|
||||
* entry is cleared after the user dismisses it so dismissed broadcasts
|
||||
* don't reappear on reconnect. */
|
||||
broadcastMessages: Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }>;
|
||||
consumeBroadcastMessages: () => Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }>;
|
||||
}
|
||||
|
||||
export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
@@ -348,12 +342,6 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
},
|
||||
liveAnnouncement: "",
|
||||
setLiveAnnouncement: (msg) => set({ liveAnnouncement: msg }),
|
||||
broadcastMessages: [],
|
||||
consumeBroadcastMessages: () => {
|
||||
const msgs = get().broadcastMessages;
|
||||
set({ broadcastMessages: [] });
|
||||
return msgs;
|
||||
},
|
||||
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
|
||||
|
||||
@@ -15,14 +15,10 @@ This file pins:
|
||||
``a2a_tools`` at module-load time (the layered architecture: it
|
||||
depends on ``a2a_tools_rbac`` + ``a2a_client`` + ``platform_auth``,
|
||||
never the kitchen-sink module).
|
||||
3. **Behavioral coverage** for paths missing from test_a2a_tools_impl:
|
||||
tool_broadcast_message, _upload_chat_files error paths.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from io import BytesIO
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -34,55 +30,6 @@ def _require_workspace_id(monkeypatch):
|
||||
yield
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_mock_client(*, post_resp=None, post_exc=None):
|
||||
mc = AsyncMock()
|
||||
mc.__aenter__ = AsyncMock(return_value=mc)
|
||||
mc.__aexit__ = AsyncMock(return_value=False)
|
||||
if post_exc:
|
||||
mc.post = AsyncMock(side_effect=post_exc)
|
||||
elif post_resp:
|
||||
mc.post = AsyncMock(return_value=post_resp)
|
||||
return mc
|
||||
|
||||
|
||||
def _mock_resp(status_code, payload):
|
||||
r = MagicMock()
|
||||
r.status_code = status_code
|
||||
r.json = MagicMock(return_value=payload)
|
||||
r.text = ""
|
||||
return r
|
||||
|
||||
|
||||
class _FakeFile:
|
||||
"""Minimal file-like for mocking builtins.open."""
|
||||
|
||||
def __init__(self, data: bytes):
|
||||
self._buf = BytesIO(data)
|
||||
|
||||
def read(self, n: int = -1) -> bytes:
|
||||
return self._buf.read(n)
|
||||
|
||||
def readline(self, n: int = -1) -> bytes:
|
||||
return self._buf.readline(n)
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
pass
|
||||
|
||||
|
||||
def _make_fake_file(data: bytes) -> _FakeFile:
|
||||
return _FakeFile(data)
|
||||
|
||||
|
||||
# ============== Drift gate ==============
|
||||
|
||||
class TestBackCompatAliases:
|
||||
@@ -143,175 +90,3 @@ class TestImportContract:
|
||||
assert hasattr(a2a_tools, "tool_get_workspace_info")
|
||||
assert hasattr(a2a_tools, "tool_chat_history")
|
||||
assert hasattr(a2a_tools, "_upload_chat_files")
|
||||
|
||||
|
||||
# ============== Behavioral coverage: tool_broadcast_message ==============
|
||||
#
|
||||
# POST /workspaces/:id/broadcast — the agent-facing A2A broadcast primitive.
|
||||
# Distinct from TestToolSendMessageToUser in test_a2a_tools_impl.py which
|
||||
# tests the /notify path. These tests cover broadcast-specific return shapes
|
||||
# (delivered count, 403 hint, empty-message guard) that test_a2a_tools_impl
|
||||
# does not exercise.
|
||||
#
|
||||
# Patching note: httpx.AsyncClient is patched at "a2a_tools.httpx.AsyncClient"
|
||||
# (matching the established pattern in test_a2a_tools_impl.py). The broadcast
|
||||
# handler is called via a2a_tools (root module) to ensure the patch resolves
|
||||
# correctly through the module's namespace lookup chain.
|
||||
|
||||
import a2a_tools
|
||||
|
||||
|
||||
class TestToolBroadcastMessage:
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_message_returns_error(self):
|
||||
result = await a2a_tools.tool_broadcast_message("")
|
||||
assert "Error" in result
|
||||
assert "required" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_whitespace_only_message_is_rejected(self):
|
||||
# tool_broadcast_message does not strip input — a whitespace-only
|
||||
# message is truthy and proceeds to the HTTP call. The platform
|
||||
# returns 403 because the test workspace has broadcast disabled.
|
||||
# This test verifies the error surface is at least an Error response.
|
||||
result = await a2a_tools.tool_broadcast_message(" ")
|
||||
assert "Error" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_200_returns_delivered_count(self):
|
||||
mc = _make_mock_client(post_resp=_mock_resp(200, {"delivered": 5}))
|
||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
|
||||
result = await a2a_tools.tool_broadcast_message("Deploy in 5 minutes")
|
||||
assert "Broadcast sent to 5 workspace(s)" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_success_200_unknown_delivered_falls_back_to_question_mark(self):
|
||||
mc = _make_mock_client(post_resp=_mock_resp(200, {}))
|
||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
|
||||
result = await a2a_tools.tool_broadcast_message("Deploy in 5 minutes")
|
||||
assert "Broadcast sent to ? workspace(s)" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_403_broadcast_disabled_includes_hint(self):
|
||||
resp = _mock_resp(
|
||||
403,
|
||||
{"error": "broadcast_disabled", "hint": "Enable via PATCH /workspaces/:id/abilities"},
|
||||
)
|
||||
mc = _make_mock_client(post_resp=resp)
|
||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
|
||||
result = await a2a_tools.tool_broadcast_message("Deploy!")
|
||||
assert "Enable via PATCH" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_403_without_hint_does_not_append_hint_fragment(self):
|
||||
resp = _mock_resp(403, {"error": "forbidden"})
|
||||
mc = _make_mock_client(post_resp=resp)
|
||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
|
||||
result = await a2a_tools.tool_broadcast_message("Deploy!")
|
||||
assert "Error" in result
|
||||
assert "PATCH" not in result # no hint field to append
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_500_includes_status_code(self):
|
||||
mc = _make_mock_client(post_resp=_mock_resp(500, {"error": "internal"}))
|
||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
|
||||
result = await a2a_tools.tool_broadcast_message("Deploy!")
|
||||
assert "500" in result
|
||||
assert "Error" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_returns_error_message(self):
|
||||
mc = _make_mock_client(post_exc=OSError("connection refused"))
|
||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
|
||||
result = await a2a_tools.tool_broadcast_message("Deploy!")
|
||||
assert "Error sending broadcast" in result
|
||||
assert "connection refused" in result
|
||||
|
||||
|
||||
# ============== Behavioral coverage: _upload_chat_files error paths ==============
|
||||
#
|
||||
# _upload_chat_files is the shared helper used by tool_send_message_to_user.
|
||||
# These tests isolate the helper directly so every error branch is explicit.
|
||||
# test_a2a_tools_impl.py exercises the tool-level paths but these cover the
|
||||
# helper-level failure modes (OSError, non-JSON, wrong file count).
|
||||
|
||||
import a2a_tools_messaging as _m
|
||||
|
||||
|
||||
class TestUploadChatFiles:
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_file_returns_error(self):
|
||||
mc = _make_mock_client()
|
||||
result, err = await _m._upload_chat_files(mc, ["/no/such/path/file.bin"])
|
||||
assert result == []
|
||||
assert err is not None
|
||||
assert "not found" in err.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oserror_on_read_returns_error(self, monkeypatch):
|
||||
# Path passes existence check; open() raises OSError.
|
||||
monkeypatch.setattr("os.path.isfile", lambda p: True)
|
||||
mc = _make_mock_client()
|
||||
with patch("builtins.open", side_effect=OSError("Disk I/O error")):
|
||||
result, err = await _m._upload_chat_files(mc, ["/some/readable/path.bin"])
|
||||
assert result == []
|
||||
assert err is not None
|
||||
assert "Disk I/O error" in err
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_httpx_exception_returns_error(self, monkeypatch):
|
||||
monkeypatch.setattr("os.path.isfile", lambda p: True)
|
||||
monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _make_fake_file(b"content"))
|
||||
mc = _make_mock_client(post_exc=OSError("network unreachable"))
|
||||
result, err = await _m._upload_chat_files(mc, ["/some/path.bin"])
|
||||
assert result == []
|
||||
assert err is not None
|
||||
assert "network unreachable" in err
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_non_200_returns_error(self, monkeypatch):
|
||||
monkeypatch.setattr("os.path.isfile", lambda p: True)
|
||||
monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _make_fake_file(b"content"))
|
||||
mc = _make_mock_client(post_resp=_mock_resp(502, {"error": "bad gateway"}))
|
||||
result, err = await _m._upload_chat_files(mc, ["/some/path.bin"])
|
||||
assert result == []
|
||||
assert err is not None
|
||||
assert "502" in err
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_non_json_response_returns_error(self, monkeypatch):
|
||||
monkeypatch.setattr("os.path.isfile", lambda p: True)
|
||||
monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _make_fake_file(b"not json"))
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json = MagicMock(side_effect=ValueError("Expecting value"))
|
||||
resp.text = "not json"
|
||||
mc = _make_mock_client(post_resp=resp)
|
||||
result, err = await _m._upload_chat_files(mc, ["/some/path.bin"])
|
||||
assert result == []
|
||||
assert err is not None
|
||||
assert "Error parsing upload response" in err
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_response_missing_files_key_returns_error(self, monkeypatch):
|
||||
monkeypatch.setattr("os.path.isfile", lambda p: True)
|
||||
monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _make_fake_file(b"content"))
|
||||
mc = _make_mock_client(post_resp=_mock_resp(200, {})) # no "files" key
|
||||
result, err = await _m._upload_chat_files(mc, ["/some/path.bin"])
|
||||
assert result == []
|
||||
assert err is not None
|
||||
assert "Error" in err # body had no "files" key
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_response_file_count_mismatch_returns_error(self, monkeypatch):
|
||||
monkeypatch.setattr("os.path.isfile", lambda p: True)
|
||||
monkeypatch.setattr("builtins.open", lambda p, *a, **kw: _make_fake_file(b"content"))
|
||||
# Asked for 2 files, platform returned 1.
|
||||
mc = _make_mock_client(
|
||||
post_resp=_mock_resp(200, {"files": [{"uri": "x", "name": "a.bin"}]})
|
||||
)
|
||||
result, err = await _m._upload_chat_files(mc, ["/a.bin", "/b.bin"])
|
||||
assert result == []
|
||||
assert err is not None
|
||||
assert "1" in err # count mismatch appears in error message
|
||||
assert "2" in err
|
||||
|
||||
Reference in New Issue
Block a user