Compare commits

...

3 Commits

Author SHA1 Message Date
fullstack-engineer a81d0951e3 fix(canvas tests): add cleanup hooks and fix jsdom accessibility queries
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 8s
sop-tier-check / tier-check (pull_request) Successful in 23s
audit-force-merge / audit (pull_request) Has been skipped
- Add afterEach(cleanup) to 8 test files that were missing it:
  ApprovalBanner, TopBar, BundleDropZone, RevealToggle, StatusBadge,
  ValidationHint, Legend, StatusDot.
- Fix StatusDot: replace screen.getByRole for aria-hidden elements
  (jsdom excludes aria-hidden from accessibility tree) with
  container.querySelector('[role="img"]').
- Fix KeyValueField: replace screen.getByRole("textbox") with
  getByLabelText — jsdom does not expose type="password" as accessible
  textbox role.
- Fix BundleDropZone: add getFileInput() helper; replace getByLabelText
  (which matched both file input AND button sharing the same aria-label)
  with container.querySelector for the file input.
- Fix Tooltip render block: add beforeEach(useFakeTimers) so tests using
  vi.advanceTimersByTime run correctly.

These fixes address DOM pollution between tests (missing cleanup hooks)
and jsdom accessibility tree limitations for aria-hidden and non-textbox
role elements — pre-existing issues discovered while verifying the
full canvas test suite after the workspace test suite passed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 20:18:43 +00:00
fullstack-engineer 4d318f84a0 fix(a2a_response): push-queue delivery_mode and comprehensive tests
Secret scan / Scan diff for credential-shaped strings (pull_request) Failing after 1s
sop-tier-check / tier-check (pull_request) Failing after 1s
- a2a_response.parse: set delivery_mode="push" for queued=True envelope
  (was missing — returned Queued() with default "poll", violating the
  push vs poll SSOT contract in a2a_response.py docstring).
- Add test fixtures for push-mode queue envelopes (full, notify, no
  method, no queue_id) and a dedicated TestPushQueuedVariant test class.
- Fix assertion in push queue test: delivery_mode=="poll" → "push".
- Correlated with PRs #333 (async test pollution) and #335 (delivery_mode).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 19:56:51 +00:00
fullstack-engineer 298b0c87ed fix(workspace): replace _run() with @pytest.mark.asyncio in inbox wrappers
Fixes #307: asyncio_mode=auto causes event-loop corruption when _run()
bypasses pytest-asyncio's lifecycle management. When conftest fixtures
initialize the loop first, a second _run() call creates a nested loop
that corrupts state — tests pass in isolation (14/14) but fail in the
full suite (0/14).

Fix: convert all 14 tests to @pytest.mark.asyncio async def + await
directly. pytest-asyncio manages the loop lifecycle; no more _run().

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 19:43:04 +00:00
13 changed files with 235 additions and 98 deletions
@@ -12,6 +12,8 @@ import { ApprovalBanner } from "../ApprovalBanner";
import { showToast } from "@/components/Toaster";
import { api } from "@/lib/api";
afterEach(cleanup);
vi.mock("@/components/Toaster", () => ({
showToast: vi.fn(),
}));
@@ -11,6 +11,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BundleDropZone } from "../BundleDropZone";
import { api } from "@/lib/api";
afterEach(cleanup);
vi.mock("@/lib/api", () => ({
api: {
post: vi.fn(),
@@ -37,12 +39,24 @@ function makeBundle(name = "test-workspace"): File {
});
}
/** Get the file input from a render result's container.
*
* NOTE: Both the hidden <input type="file"> and the visible <button> share
* aria-label="Import bundle file", so getByLabelText finds multiple elements.
* Use this helper instead to scope the query to the file input in the container.
*/
function getFileInput(r: ReturnType<typeof render>): HTMLInputElement {
const input = r.container.querySelector<HTMLInputElement>('input[type="file"]');
if (!input) throw new Error("No file input found");
return input;
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("BundleDropZone — render", () => {
it("renders a hidden file input with correct accept and aria-label", () => {
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const r = render(<BundleDropZone />);
const input = getFileInput(r);
expect(input.getAttribute("type")).toBe("file");
expect(input.getAttribute("accept")).toBe(".bundle.json");
});
@@ -91,8 +105,8 @@ describe("BundleDropZone — drag state", () => {
describe("BundleDropZone — keyboard file input (WCAG 2.1.1)", () => {
it("triggers the hidden file input when the import button is clicked", () => {
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file") as HTMLInputElement;
const r = render(<BundleDropZone />);
const input = getFileInput(r);
const clickSpy = vi.spyOn(input, "click");
fireEvent.click(screen.getByRole("button", { name: /import bundle/i }));
expect(clickSpy).toHaveBeenCalled();
@@ -106,8 +120,8 @@ describe("BundleDropZone — keyboard file input (WCAG 2.1.1)", () => {
status: "online",
});
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const r = render(<BundleDropZone />);
const input = getFileInput(r);
const file = makeBundle("My Bundle");
Object.defineProperty(input, "files", {
@@ -138,8 +152,8 @@ describe("BundleDropZone — import success", () => {
status: "online",
});
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const r = render(<BundleDropZone />);
const input = getFileInput(r);
const file = makeBundle("Success Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -169,8 +183,8 @@ describe("BundleDropZone — import success", () => {
status: "online",
});
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const r = render(<BundleDropZone />);
const input = getFileInput(r);
const file = makeBundle("Timed Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -195,8 +209,8 @@ describe("BundleDropZone — import error", () => {
vi.useFakeTimers();
vi.mocked(api.post).mockRejectedValueOnce(new Error("Import failed: 500 Internal Server Error"));
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const r = render(<BundleDropZone />);
const input = getFileInput(r);
const file = makeBundle("Failed Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -213,8 +227,8 @@ describe("BundleDropZone — import error", () => {
it("shows error when file is not a .bundle.json", async () => {
vi.useFakeTimers();
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const r = render(<BundleDropZone />);
const input = getFileInput(r);
const file = new File(["{}"], "readme.txt", { type: "text/plain" });
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -238,8 +252,8 @@ describe("BundleDropZone — import error", () => {
vi.useFakeTimers();
vi.mocked(api.post).mockRejectedValueOnce(new Error("Network error"));
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const r = render(<BundleDropZone />);
const input = getFileInput(r);
const file = makeBundle("Error Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -266,8 +280,8 @@ describe("BundleDropZone — importing state", () => {
const pending = new Promise((r) => { resolve = r; });
vi.mocked(api.post).mockReturnValueOnce(pending as unknown as ReturnType<typeof api.post>);
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const r = render(<BundleDropZone />);
const input = getFileInput(r);
const file = makeBundle("Pending Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -298,8 +312,8 @@ describe("BundleDropZone — file input reset", () => {
status: "online",
});
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file") as HTMLInputElement;
const r = render(<BundleDropZone />);
const input = getFileInput(r);
const file = makeBundle("Reset Test");
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -5,6 +5,11 @@
* Covers: renders password input, type=text when revealed,
* onChange prop, auto-trim on paste, auto-hide after 30s,
* disabled state, aria-label.
*
* NOTE: getByRole("textbox") is replaced with getByLabelText because
* jsdom's accessibility tree does not expose type="password" inputs as
* accessible textbox role — querySelector is used as a fallback where
* aria-label is not available.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
@@ -22,12 +27,12 @@ describe("KeyValueField — render", () => {
it("renders a password input by default", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(screen.getByRole("textbox").getAttribute("type")).toBe("password");
expect(screen.getByLabelText("Secret value").getAttribute("type")).toBe("password");
});
it("renders a text input when revealed=true", () => {
const { container } = render(<KeyValueField value="secret" onChange={vi.fn()} />);
// Cannot use getByRole because type=text inputs may not be queryable as textbox in jsdom
// Use querySelector since we don't change the aria-label
const input = container.querySelector("input");
expect(input).toBeTruthy();
expect(input!.getAttribute("type")).toBe("password");
@@ -35,32 +40,32 @@ describe("KeyValueField — render", () => {
it("uses the provided aria-label", () => {
render(<KeyValueField value="" onChange={vi.fn()} aria-label="My secret field" />);
expect(screen.getByRole("textbox").getAttribute("aria-label")).toBe("My secret field");
expect(screen.getByLabelText("My secret field").getAttribute("aria-label")).toBe("My secret field");
});
it("uses default aria-label when omitted", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(screen.getByRole("textbox").getAttribute("aria-label")).toBe("Secret value");
expect(screen.getByLabelText("Secret value").getAttribute("aria-label")).toBe("Secret value");
});
it("renders a disabled input when disabled=true", () => {
render(<KeyValueField value="x" onChange={vi.fn()} disabled={true} />);
expect(screen.getByRole("textbox").getAttribute("disabled")).toBe("");
expect(screen.getByLabelText("Secret value").getAttribute("disabled")).toBe("");
});
it("renders with the provided placeholder", () => {
render(<KeyValueField value="" onChange={vi.fn()} placeholder="Enter API key" />);
expect(screen.getByRole("textbox").getAttribute("placeholder")).toBe("Enter API key");
expect(screen.getByLabelText("Secret value").getAttribute("placeholder")).toBe("Enter API key");
});
it("disables spell-check on the input", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(screen.getByRole("textbox").getAttribute("spellcheck")).toBe("false");
expect(screen.getByLabelText("Secret value").getAttribute("spellcheck")).toBe("false");
});
it("sets autoComplete=off on the input", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(screen.getByRole("textbox").getAttribute("autocomplete")).toBe("off");
expect(screen.getByLabelText("Secret value").getAttribute("autocomplete")).toBe("off");
});
});
@@ -74,28 +79,28 @@ describe("KeyValueField — onChange", () => {
it("calls onChange when input changes", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "abc" } });
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "abc" } });
expect(onChange).toHaveBeenCalledWith("abc");
});
it("trims trailing whitespace on change", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "abc " } });
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "abc " } });
expect(onChange).toHaveBeenCalledWith("abc");
});
it("trims leading whitespace on change", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: " abc" } });
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: " abc" } });
expect(onChange).toHaveBeenCalledWith("abc");
});
it("passes value through unchanged when no whitespace trimming needed", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "no-change" } });
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "no-change" } });
expect(onChange).toHaveBeenCalledWith("no-change");
});
});
@@ -11,6 +11,8 @@ import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { Legend } from "../Legend";
import { useCanvasStore } from "@/store/canvas";
afterEach(cleanup);
// ─── Mock localStorage ────────────────────────────────────────────────────────
const localStorageMock = (() => {
@@ -6,10 +6,12 @@
* aria-label, title text, onToggle callback.
*/
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RevealToggle } from "../ui/RevealToggle";
afterEach(cleanup);
describe("RevealToggle — render", () => {
it("renders a button element", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
@@ -6,10 +6,12 @@
* icon presence, className variants, no render when passed invalid status.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { StatusBadge } from "../ui/StatusBadge";
afterEach(cleanup);
describe("StatusBadge — render", () => {
it("renders verified status with ✓ icon", () => {
render(<StatusBadge status="verified" />);
@@ -10,83 +10,94 @@
* - aria-hidden="true" and role="img" for accessibility
* - provisioning status carries motion-safe:animate-pulse for the pulsing effect
* - glow class applied when STATUS_CONFIG declares one
*
* NOTE: aria-hidden elements are excluded from jsdom's accessibility tree, so
* getByRole("img") cannot find them. Use container.querySelector instead.
*/
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react";
import React from "react";
import { StatusDot } from "../StatusDot";
afterEach(cleanup);
function getStatusDot(container: HTMLElement) {
const el = container.querySelector<HTMLElement>('[role="img"]');
if (!el) throw new Error("No role=img element found");
return el;
}
describe("StatusDot — snapshot", () => {
it("renders with online status", () => {
render(<StatusDot status="online" />);
const dot = screen.getByRole("img");
const { container } = render(<StatusDot status="online" />);
const dot = getStatusDot(container);
expect(dot.className).toContain("bg-emerald-400");
expect(dot.className).toContain("shadow-emerald-400/50");
expect(dot.getAttribute("aria-hidden")).toBe("true");
});
it("renders with offline status", () => {
render(<StatusDot status="offline" />);
const dot = screen.getByRole("img");
const { container } = render(<StatusDot status="offline" />);
const dot = getStatusDot(container);
expect(dot.className).toContain("bg-zinc-500");
// offline has no glow
expect(dot.className).not.toContain("shadow-");
});
it("renders with degraded status", () => {
render(<StatusDot status="degraded" />);
const dot = screen.getByRole("img");
const { container } = render(<StatusDot status="degraded" />);
const dot = getStatusDot(container);
expect(dot.className).toContain("bg-amber-400");
expect(dot.className).toContain("shadow-amber-400/50");
});
it("renders with failed status", () => {
render(<StatusDot status="failed" />);
const dot = screen.getByRole("img");
const { container } = render(<StatusDot status="failed" />);
const dot = getStatusDot(container);
expect(dot.className).toContain("bg-red-400");
expect(dot.className).toContain("shadow-red-400/50");
});
it("renders with paused status", () => {
render(<StatusDot status="paused" />);
const dot = screen.getByRole("img");
const { container } = render(<StatusDot status="paused" />);
const dot = getStatusDot(container);
expect(dot.className).toContain("bg-indigo-400");
});
it("renders with not_configured status", () => {
render(<StatusDot status="not_configured" />);
const dot = screen.getByRole("img");
const { container } = render(<StatusDot status="not_configured" />);
const dot = getStatusDot(container);
expect(dot.className).toContain("bg-amber-300");
expect(dot.className).toContain("shadow-amber-300/50");
});
it("renders with provisioning status and pulsing animation", () => {
render(<StatusDot status="provisioning" />);
const dot = screen.getByRole("img");
const { container } = render(<StatusDot status="provisioning" />);
const dot = getStatusDot(container);
expect(dot.className).toContain("bg-sky-400");
expect(dot.className).toContain("motion-safe:animate-pulse");
expect(dot.className).toContain("shadow-sky-400/50");
});
it("falls back to bg-zinc-500 for unknown status", () => {
render(<StatusDot status="alien_artifact" />);
const dot = screen.getByRole("img");
const { container } = render(<StatusDot status="alien_artifact" />);
const dot = getStatusDot(container);
expect(dot.className).toContain("bg-zinc-500");
});
});
describe("StatusDot — size prop", () => {
it("applies w-2 h-2 (sm, default)", () => {
render(<StatusDot status="online" />);
const dot = screen.getByRole("img");
const { container } = render(<StatusDot status="online" />);
const dot = getStatusDot(container);
expect(dot.className).toContain("w-2");
expect(dot.className).toContain("h-2");
});
it("applies w-2.5 h-2.5 (md)", () => {
render(<StatusDot status="online" size="md" />);
const dot = screen.getByRole("img");
const { container } = render(<StatusDot status="online" size="md" />);
const dot = getStatusDot(container);
expect(dot.className).toContain("w-2.5");
expect(dot.className).toContain("h-2.5");
});
@@ -94,7 +105,7 @@ describe("StatusDot — size prop", () => {
describe("StatusDot — accessibility", () => {
it("is aria-hidden so it doesn't pollute the accessibility tree", () => {
render(<StatusDot status="online" />);
expect(screen.getByRole("img").getAttribute("aria-hidden")).toBe("true");
const { container } = render(<StatusDot status="online" />);
expect(getStatusDot(container).getAttribute("aria-hidden")).toBe("true");
});
});
@@ -13,6 +13,9 @@ import { Tooltip } from "../Tooltip";
afterEach(cleanup);
describe("Tooltip — render", () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it("renders children without showing tooltip on mount", () => {
render(
<Tooltip text="Hello world">
@@ -6,10 +6,12 @@
* SettingsButton integration, custom canvasName prop.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TopBar } from "../canvas/TopBar";
afterEach(cleanup);
// ─── Mock SettingsButton ───────────────────────────────────────────────────────
vi.mock("../settings/SettingsButton", () => ({
@@ -6,10 +6,12 @@
* aria-live for error, icon rendering.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { ValidationHint } from "../ui/ValidationHint";
afterEach(cleanup);
describe("ValidationHint — error state", () => {
it("renders error message when error is a non-null string", () => {
render(<ValidationHint error="Invalid email address" />);
+1 -1
View File
@@ -194,7 +194,7 @@ def parse(data: Any) -> Variant:
method,
data.get("queue_id", "?"),
)
return Queued(method=method)
return Queued(method=method, delivery_mode="push")
# Poll-queued envelope. Both keys must be present — the workspace
# server sets them together; if only one is present the body is
+83
View File
@@ -115,12 +115,91 @@ _FIXTURES = {
"malformed_delivery_mode_no_status": {
"delivery_mode": "poll",
},
# --- Push-mode queue envelopes ---
# Returned when a push-mode workspace (has public URL) is at capacity.
# The platform queues the request and returns {"queued": true, ...}.
# Distinguishable from poll-mode by data.get("queued") is True alone.
"push_queued_full": {
"queued": True,
"method": "message/send",
"queue_id": "q-1",
},
"push_queued_notify": {
"queued": True,
"method": "notify",
"queue_id": "q-2",
},
"push_queued_no_method": {
# method absent — parser must not raise; falls back to "message/send".
"queued": True,
"queue_id": "q-3",
},
"push_queued_no_queue_id": {
# queue_id absent — parser must not raise; logs queue_id="?".
"queued": True,
"method": "message/send",
},
}
# ============== Variant-by-variant coverage ==============
class TestPushQueuedVariant:
"""``parse()`` returns ``Queued`` for push-mode at-capacity envelope
(lines 189-197 of a2a_response.py): ``{"queued": true, ...}``.
The push-mode path was added in PR #278 alongside the a2a_proxy.go
push-at-capacity branch. Lines 182-197 were not covered until this test.
"""
def test_full_envelope_message_send(self):
v = a2a_response.parse(_FIXTURES["push_queued_full"])
assert isinstance(v, a2a_response.Queued)
assert v.method == "message/send"
assert v.delivery_mode == "push"
def test_envelope_with_notify(self):
v = a2a_response.parse(_FIXTURES["push_queued_notify"])
assert isinstance(v, a2a_response.Queued)
assert v.method == "notify"
def test_envelope_missing_method_falls_back_to_message_send(self):
# a2a_response.py:191 — method_raw is None, defaults to "message/send".
v = a2a_response.parse(_FIXTURES["push_queued_no_method"])
assert isinstance(v, a2a_response.Queued)
assert v.method == "message/send"
def test_envelope_missing_queue_id_still_queued(self):
# queue_id is purely informational; its absence must not break parsing.
v = a2a_response.parse(_FIXTURES["push_queued_no_queue_id"])
assert isinstance(v, a2a_response.Queued)
assert v.method == "message/send"
def test_push_queued_is_distinct_from_poll_queued(self):
# Same Queued variant, but from different wire shapes. Confirm both paths.
push_v = a2a_response.parse(_FIXTURES["push_queued_full"])
poll_v = a2a_response.parse(_FIXTURES["poll_queued_full"])
assert isinstance(push_v, a2a_response.Queued)
assert isinstance(poll_v, a2a_response.Queued)
assert push_v.method == poll_v.method == "message/send"
def test_logs_info_on_push_queued(self, caplog):
with caplog.at_level(logging.INFO, logger="a2a_response"):
a2a_response.parse(_FIXTURES["push_queued_full"])
assert any("queued for busy push-mode peer" in r.message for r in caplog.records)
assert any("queue_id=q-1" in r.message for r in caplog.records)
def test_queued_true_is_distinct_from_queued_truthy(self):
# "queued": 1 / "queued": "yes" — these are truthy but not True,
# and must NOT trigger the push-mode path. Route to Malformed instead.
v = a2a_response.parse({"queued": 1})
assert isinstance(v, a2a_response.Malformed)
v = a2a_response.parse({"queued": "yes"})
assert isinstance(v, a2a_response.Malformed)
class TestQueuedVariant:
"""``parse()`` recognizes the workspace-server poll-mode short-circuit
envelope (a2a_proxy.go:402-406) and returns ``Queued``."""
@@ -436,6 +515,10 @@ class TestRegressionGate:
"poll_queued_full": a2a_response.Queued,
"poll_queued_notify": a2a_response.Queued,
"poll_queued_no_method": a2a_response.Queued,
"push_queued_full": a2a_response.Queued,
"push_queued_notify": a2a_response.Queued,
"push_queued_no_method": a2a_response.Queued,
"push_queued_no_queue_id": a2a_response.Queued,
"malformed_empty_dict": a2a_response.Malformed,
"malformed_unexpected_keys": a2a_response.Malformed,
"malformed_status_queued_no_delivery_mode": a2a_response.Malformed,
@@ -15,7 +15,6 @@ The wrappers are ~40 LOC of glue. The full delivery behavior
"""
from __future__ import annotations
import asyncio
import json
from unittest.mock import MagicMock, patch
@@ -29,24 +28,22 @@ def _require_workspace_id(monkeypatch):
yield
def _run(coro):
return asyncio.get_event_loop().run_until_complete(coro)
# ---------------------------------------------------------------------------
# tool_inbox_peek
# ---------------------------------------------------------------------------
class TestToolInboxPeek:
def test_returns_not_enabled_when_state_none(self):
@pytest.mark.asyncio
async def test_returns_not_enabled_when_state_none(self):
import a2a_tools
with patch("inbox.get_state", return_value=None):
out = _run(a2a_tools.tool_inbox_peek())
out = await a2a_tools.tool_inbox_peek()
assert "not enabled" in out
def test_returns_json_array_of_messages(self):
@pytest.mark.asyncio
async def test_returns_json_array_of_messages(self):
import a2a_tools
msg1 = MagicMock()
@@ -58,20 +55,21 @@ class TestToolInboxPeek:
fake_state.peek.return_value = [msg1, msg2]
with patch("inbox.get_state", return_value=fake_state):
out = _run(a2a_tools.tool_inbox_peek(limit=5))
out = await a2a_tools.tool_inbox_peek(limit=5)
# peek limit is forwarded
fake_state.peek.assert_called_once_with(limit=5)
parsed = json.loads(out)
assert len(parsed) == 2
assert parsed[0]["activity_id"] == "a1"
def test_non_int_limit_falls_back_to_10(self):
@pytest.mark.asyncio
async def test_non_int_limit_falls_back_to_10(self):
import a2a_tools
fake_state = MagicMock()
fake_state.peek.return_value = []
with patch("inbox.get_state", return_value=fake_state):
_run(a2a_tools.tool_inbox_peek(limit="garbage")) # type: ignore[arg-type]
await a2a_tools.tool_inbox_peek(limit="garbage") # type: ignore[arg-type]
fake_state.peek.assert_called_once_with(limit=10)
@@ -81,49 +79,54 @@ class TestToolInboxPeek:
class TestToolInboxPop:
def test_returns_not_enabled_when_state_none(self):
@pytest.mark.asyncio
async def test_returns_not_enabled_when_state_none(self):
import a2a_tools
with patch("inbox.get_state", return_value=None):
out = _run(a2a_tools.tool_inbox_pop("act-1"))
out = await a2a_tools.tool_inbox_pop("act-1")
assert "not enabled" in out
def test_rejects_empty_activity_id(self):
@pytest.mark.asyncio
async def test_rejects_empty_activity_id(self):
import a2a_tools
fake_state = MagicMock()
with patch("inbox.get_state", return_value=fake_state):
out = _run(a2a_tools.tool_inbox_pop(""))
out = await a2a_tools.tool_inbox_pop("")
assert "activity_id is required" in out
fake_state.pop.assert_not_called()
def test_rejects_non_str_activity_id(self):
@pytest.mark.asyncio
async def test_rejects_non_str_activity_id(self):
import a2a_tools
fake_state = MagicMock()
with patch("inbox.get_state", return_value=fake_state):
out = _run(a2a_tools.tool_inbox_pop(123)) # type: ignore[arg-type]
out = await a2a_tools.tool_inbox_pop(123) # type: ignore[arg-type]
assert "activity_id is required" in out
fake_state.pop.assert_not_called()
def test_returns_removed_true_when_popped(self):
@pytest.mark.asyncio
async def test_returns_removed_true_when_popped(self):
import a2a_tools
fake_state = MagicMock()
fake_state.pop.return_value = MagicMock() # truthy = something was removed
with patch("inbox.get_state", return_value=fake_state):
out = _run(a2a_tools.tool_inbox_pop("act-7"))
out = await a2a_tools.tool_inbox_pop("act-7")
parsed = json.loads(out)
assert parsed == {"removed": True, "activity_id": "act-7"}
fake_state.pop.assert_called_once_with("act-7")
def test_returns_removed_false_when_unknown(self):
@pytest.mark.asyncio
async def test_returns_removed_false_when_unknown(self):
import a2a_tools
fake_state = MagicMock()
fake_state.pop.return_value = None
with patch("inbox.get_state", return_value=fake_state):
out = _run(a2a_tools.tool_inbox_pop("act-missing"))
out = await a2a_tools.tool_inbox_pop("act-missing")
parsed = json.loads(out)
assert parsed == {"removed": False, "activity_id": "act-missing"}
@@ -134,25 +137,28 @@ class TestToolInboxPop:
class TestToolWaitForMessage:
def test_returns_not_enabled_when_state_none(self):
@pytest.mark.asyncio
async def test_returns_not_enabled_when_state_none(self):
import a2a_tools
with patch("inbox.get_state", return_value=None):
out = _run(a2a_tools.tool_wait_for_message(timeout_secs=1.0))
out = await a2a_tools.tool_wait_for_message(timeout_secs=1.0)
assert "not enabled" in out
def test_timeout_payload_when_no_message(self):
@pytest.mark.asyncio
async def test_timeout_payload_when_no_message(self):
import a2a_tools
fake_state = MagicMock()
fake_state.wait.return_value = None
with patch("inbox.get_state", return_value=fake_state):
out = _run(a2a_tools.tool_wait_for_message(timeout_secs=0.1))
out = await a2a_tools.tool_wait_for_message(timeout_secs=0.1)
parsed = json.loads(out)
assert parsed["timeout"] is True
assert parsed["timeout_secs"] == 0.1
def test_returns_message_when_delivered(self):
@pytest.mark.asyncio
async def test_returns_message_when_delivered(self):
import a2a_tools
msg = MagicMock()
@@ -160,37 +166,40 @@ class TestToolWaitForMessage:
fake_state = MagicMock()
fake_state.wait.return_value = msg
with patch("inbox.get_state", return_value=fake_state):
out = _run(a2a_tools.tool_wait_for_message(timeout_secs=2.0))
out = await a2a_tools.tool_wait_for_message(timeout_secs=2.0)
parsed = json.loads(out)
assert parsed["activity_id"] == "a-9"
def test_timeout_clamped_to_300(self):
@pytest.mark.asyncio
async def test_timeout_clamped_to_300(self):
import a2a_tools
fake_state = MagicMock()
fake_state.wait.return_value = None
with patch("inbox.get_state", return_value=fake_state):
_run(a2a_tools.tool_wait_for_message(timeout_secs=99999))
await a2a_tools.tool_wait_for_message(timeout_secs=99999)
# Whatever wait was called with, it must not exceed 300
passed = fake_state.wait.call_args.args[0]
assert passed == 300.0
def test_timeout_clamped_to_zero_floor(self):
@pytest.mark.asyncio
async def test_timeout_clamped_to_zero_floor(self):
import a2a_tools
fake_state = MagicMock()
fake_state.wait.return_value = None
with patch("inbox.get_state", return_value=fake_state):
_run(a2a_tools.tool_wait_for_message(timeout_secs=-5))
await a2a_tools.tool_wait_for_message(timeout_secs=-5)
passed = fake_state.wait.call_args.args[0]
assert passed == 0.0
def test_non_numeric_timeout_falls_back_to_60(self):
@pytest.mark.asyncio
async def test_non_numeric_timeout_falls_back_to_60(self):
import a2a_tools
fake_state = MagicMock()
fake_state.wait.return_value = None
with patch("inbox.get_state", return_value=fake_state):
_run(a2a_tools.tool_wait_for_message(timeout_secs="garbage")) # type: ignore[arg-type]
await a2a_tools.tool_wait_for_message(timeout_secs="garbage") # type: ignore[arg-type]
passed = fake_state.wait.call_args.args[0]
assert passed == 60.0