fix(a2a_response): push-queue delivery_mode and comprehensive tests #341
@ -32,11 +32,9 @@ on:
|
||||
- '.gitea/workflows/publish-workspace-server-image.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Serialize per-branch so two rapid staging pushes don't race the same
|
||||
# :staging-latest tag retag. Allow staging and main to run in parallel
|
||||
# (different GITHUB_REF → different concurrency group) since they
|
||||
# produce different :staging-<sha> tags and last-write-wins on
|
||||
# :staging-latest is acceptable across branches.
|
||||
# Serialize per-branch so two rapid main pushes don't race the same
|
||||
# :staging-latest tag retag. Allow parallel runs as they produce
|
||||
# different :staging-<sha> tags and last-write-wins on :staging-latest.
|
||||
#
|
||||
# cancel-in-progress: false → in-flight builds finish; the next push's
|
||||
# build queues. This avoids a partially-pushed image.
|
||||
|
||||
1
.staging-trigger
Normal file
1
.staging-trigger
Normal file
@ -0,0 +1 @@
|
||||
staging trigger
|
||||
@ -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" />);
|
||||
|
||||
@ -44,3 +44,4 @@
|
||||
{"name": "mock-bigorg", "repo": "molecule-ai/molecule-ai-org-template-mock-bigorg", "ref": "main"}
|
||||
]
|
||||
}
|
||||
// Triggered by Integration Tester at 2026-05-10T08:52Z
|
||||
|
||||
@ -37,6 +37,50 @@ PLUGINS_DIR="${4:?Missing plugins dir}"
|
||||
EXPECTED=0
|
||||
CLONED=0
|
||||
|
||||
# clone_one_with_retry — clone a single repo, retrying on transient failure.
|
||||
#
|
||||
# Why: the publish-workspace-server-image (and harness-replays) CI jobs
|
||||
# clone the full manifest (~36 repos) serially on a memory-constrained
|
||||
# Gitea Actions runner. Under host memory pressure the OOM killer
|
||||
# occasionally SIGKILLs git-remote-https mid-clone:
|
||||
#
|
||||
# error: git-remote-https died of signal 9
|
||||
# fatal: the remote end hung up unexpectedly
|
||||
#
|
||||
# (observed in publish-workspace-server-image run 4622 on 2026-05-10 — the
|
||||
# job died on the 14th of 36 clones, which wedged staging→main). One
|
||||
# transient SIGKILL / network blip would otherwise fail the whole tenant
|
||||
# image rebuild. Retrying after a short backoff lets the pressure subside.
|
||||
# The durable fix is more runner RAM/swap (tracked with Infra-SRE); this
|
||||
# just stops a single flake from being release-blocking.
|
||||
#
|
||||
# Args: <target_dir> <name> <clone_url> <display_url> <ref>
|
||||
clone_one_with_retry() {
|
||||
local tdir="$1" name="$2" url="$3" display="$4" ref="$5"
|
||||
local attempt=1 max_attempts=3 backoff
|
||||
|
||||
while : ; do
|
||||
# A killed attempt can leave a partial directory behind; git clone
|
||||
# refuses a non-empty target, so wipe it before each try.
|
||||
rm -rf "$tdir/$name"
|
||||
|
||||
if [ "$ref" = "main" ]; then
|
||||
if git clone --depth=1 -q "$url" "$tdir/$name"; then return 0; fi
|
||||
else
|
||||
if git clone --depth=1 -q --branch "$ref" "$url" "$tdir/$name"; then return 0; fi
|
||||
fi
|
||||
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
echo "::error::clone failed after ${max_attempts} attempts: ${display}" >&2
|
||||
return 1
|
||||
fi
|
||||
backoff=$((attempt * 3)) # 3s, then 6s
|
||||
echo " ⚠ clone attempt ${attempt}/${max_attempts} failed for ${display} — retrying in ${backoff}s" >&2
|
||||
sleep "$backoff"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
}
|
||||
|
||||
clone_category() {
|
||||
local category="$1"
|
||||
local target_dir="$2"
|
||||
@ -82,11 +126,7 @@ clone_category() {
|
||||
fi
|
||||
|
||||
echo " cloning $display_url -> $target_dir/$name (ref=$ref)"
|
||||
if [ "$ref" = "main" ]; then
|
||||
git clone --depth=1 -q "$clone_url" "$target_dir/$name"
|
||||
else
|
||||
git clone --depth=1 -q --branch "$ref" "$clone_url" "$target_dir/$name"
|
||||
fi
|
||||
clone_one_with_retry "$target_dir" "$name" "$clone_url" "$display_url" "$ref"
|
||||
CLONED=$((CLONED + 1))
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
@ -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
|
||||
|
||||
@ -77,6 +77,16 @@ async def delegate_task(workspace_id: str, task: str) -> str:
|
||||
return str(result) if isinstance(result, str) else "(no text)"
|
||||
elif "error" in data:
|
||||
err = data["error"]
|
||||
# Handle both string-form errors ("error": "some string")
|
||||
# and object-form errors ("error": {"message": "...", "code": ...}).
|
||||
msg = ""
|
||||
if isinstance(err, dict):
|
||||
msg = err.get("message", "")
|
||||
elif isinstance(err, str):
|
||||
msg = err
|
||||
else:
|
||||
msg = str(err)
|
||||
return f"Error: {msg}"
|
||||
msg = ""
|
||||
if isinstance(err, dict):
|
||||
msg = err.get("message", "")
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user