Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// @vitest-environment jsdom
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";

import type { ProgressBlock } from "@/components/app/agent-surfaces/types";
import { ProgressBlockView } from "./progress-block";

afterEach(() => {
cleanup();
});

function block(overrides: Partial<ProgressBlock> = {}): ProgressBlock {
return {
id: "p1",
type: "progress",
value: 3,
max: 10,
...overrides,
} as ProgressBlock;
}

describe("ProgressBlockView percent and clamping", () => {
it("computes percent from value/max and mirrors it on the ARIA range", () => {
render(<ProgressBlockView block={block({ value: 3, max: 10 })} />);

expect(screen.getByText("30%")).toBeTruthy();
const bar = screen.getByRole("progressbar");
expect(bar.getAttribute("aria-valuenow")).toBe("3");
expect(bar.getAttribute("aria-valuemax")).toBe("10");
});

it("clamps a negative value to 0 instead of going negative", () => {
render(<ProgressBlockView block={block({ value: -5, max: 10 })} />);

expect(screen.getByText("0%")).toBeTruthy();
expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe(
"0"
);
});

it("clamps a value above max down to max instead of overshooting 100%", () => {
render(<ProgressBlockView block={block({ value: 25, max: 10 })} />);

expect(screen.getByText("100%")).toBeTruthy();
expect(screen.getByRole("progressbar").getAttribute("aria-valuenow")).toBe(
"10"
);
});

it("treats a non-positive max as 1 rather than dividing by zero", () => {
render(<ProgressBlockView block={block({ value: 0, max: 0 })} />);

expect(screen.getByText("0%")).toBeTruthy();
expect(screen.getByRole("progressbar").getAttribute("aria-valuemax")).toBe(
"1"
);
});

it("rounds a fractional percent to the nearest whole number", () => {
render(<ProgressBlockView block={block({ value: 2, max: 3 })} />);

// 2/3 = 66.66...% — rounds up to 67, where floor would give 66.
expect(screen.getByText("67%")).toBeTruthy();
});
});

describe("ProgressBlockView tone", () => {
it("gives a toned bar a different fill class than the neutral default", () => {
const { container: neutralContainer } = render(
<ProgressBlockView block={block({})} />
);
const neutralBar = neutralContainer.querySelector(
'[role="progressbar"] > div'
)!;
cleanup();

const { container: warningContainer } = render(
<ProgressBlockView block={block({ tone: "warning" })} />
);
const warningBar = warningContainer.querySelector(
'[role="progressbar"] > div'
)!;

expect(warningBar.className).not.toBe(neutralBar.className);
});
});

describe("ProgressBlockView label/title precedence", () => {
it("puts the title in the heading and the label on the progress line when both are set", () => {
render(
<ProgressBlockView
block={block({ title: "Deploy", label: "3 of 10 steps" })}
/>
);

expect(screen.getByRole("heading", { name: "Deploy" })).toBeTruthy();
expect(screen.getByText("3 of 10 steps")).toBeTruthy();
});

it("falls back to the title on the progress line and renders no heading when there is no label", () => {
render(<ProgressBlockView block={block({ title: "Deploy" })} />);

expect(screen.queryByRole("heading")).toBeNull();
expect(screen.getByText("Deploy")).toBeTruthy();
});

it("falls back the ARIA label from label to title to a generic default", () => {
const { rerender } = render(
<ProgressBlockView
block={block({ title: "Deploy", label: "3 of 10 steps" })}
/>
);
expect(screen.getByRole("progressbar").getAttribute("aria-label")).toBe(
"3 of 10 steps"
);

rerender(<ProgressBlockView block={block({ title: "Deploy" })} />);
expect(screen.getByRole("progressbar").getAttribute("aria-label")).toBe(
"Deploy"
);

rerender(<ProgressBlockView block={block({})} />);
expect(screen.getByRole("progressbar").getAttribute("aria-label")).toBe(
"Progress"
);
});

it("renders the detail markdown only when provided", () => {
const { rerender } = render(
<ProgressBlockView block={block({ detail: "Extra context." })} />
);
expect(screen.getByText("Extra context.")).toBeTruthy();

rerender(<ProgressBlockView block={block({})} />);
expect(screen.queryByText("Extra context.")).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import type { ActionRef } from "@/components/app/agent-surfaces/types";
import {
indexInteractions,
type SurfaceInteractionIndex,
} from "@/components/app/agent-surfaces/interaction-presentation";
import { SlotActions } from "./slot-actions";

const mutate = vi.fn();
vi.mock("@/hooks/use-agent-surfaces", () => ({
makeIdempotencyKey: () => "idem-test",
useSubmitSurfaceInteraction: () => ({ mutate }),
}));

afterEach(() => {
cleanup();
mutate.mockReset();
});

function renderActions(
actions: ActionRef[],
overrides: {
interactions?: SurfaceInteractionIndex;
readOnly?: boolean;
surfaceRevision?: number;
} = {}
) {
return render(
<SlotActions
blockId="footer"
actions={actions}
agentId="agt_test"
surfaceId="surface_test"
surfaceRevision={overrides.surfaceRevision ?? 1}
interactions={overrides.interactions ?? new Map()}
onRequestRefresh={async () => {}}
readOnly={overrides.readOnly ?? false}
idPrefix="test"
/>
);
}

// Buttons that submit an action (main or plain, not the menu trigger) all
// carry data-action-id — the menu trigger itself does not.
function actionButtonIds(): string[] {
return Array.from(document.querySelectorAll("[data-action-id]")).map(
(el) => el.getAttribute("data-action-id")!
);
}

describe("SlotActions main-action selection", () => {
it("renders a single action as a plain button with no overflow menu", () => {
renderActions([{ id: "go", label: "Go", intent: "go" }]);

expect(screen.getByRole("button", { name: "Go" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "More actions" })).toBeNull();
});

it("keeps authored order for two non-destructive actions with no primary", () => {
renderActions([
{ id: "a", label: "A", intent: "a" },
{ id: "b", label: "B", intent: "b" },
]);

expect(actionButtonIds()).toEqual(["a", "b"]);
expect(screen.queryByRole("button", { name: "More actions" })).toBeNull();
});

it("promotes a primary-styled action to the front regardless of authored order", () => {
renderActions([
{ id: "a", label: "A", intent: "a" },
{ id: "b", label: "B", intent: "b", style: "primary" },
]);

// b is primary, so it becomes `main` and renders first even though it
// was authored second.
expect(actionButtonIds()).toEqual(["b", "a"]);
});

it("falls back to the first action when every action is destructive", () => {
renderActions([
{ id: "a", label: "Delete", intent: "delete", style: "destructive" },
]);

// A single action never needs a menu, even when destructive.
expect(screen.getByRole("button", { name: "Delete" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "More actions" })).toBeNull();
});

it("picks the first non-destructive action as main even when authored after a destructive one", () => {
renderActions([
{ id: "del", label: "Delete", intent: "delete", style: "destructive" },
{ id: "save", label: "Save", intent: "save" },
]);

expect(actionButtonIds()).toEqual(["save"]);
expect(screen.getByRole("button", { name: "Save" })).toBeTruthy();
expect(screen.getByRole("button", { name: "More actions" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Delete" })).toBeNull();
});
});

describe("SlotActions overflow menu", () => {
it("opens a menu once there is more than one non-main action", () => {
renderActions([
{ id: "a", label: "Save", intent: "save" },
{ id: "b", label: "Retry", intent: "retry" },
{ id: "c", label: "Archive", intent: "archive" },
]);

expect(screen.getByRole("button", { name: "More actions" })).toBeTruthy();
expect(actionButtonIds()).toEqual(["a"]);
});
});

describe("SlotActions confirm flow", () => {
const confirmAction: ActionRef = {
id: "delete",
label: "Delete",
intent: "delete",
confirm: { title: "Delete this?", description: "This can't be undone." },
};

it("opens the confirm dialog instead of submitting immediately", () => {
renderActions([confirmAction]);

fireEvent.click(screen.getByRole("button", { name: "Delete" }));

expect(screen.getByText("Delete this?")).toBeTruthy();
expect(screen.getByText("This can't be undone.")).toBeTruthy();
expect(mutate).not.toHaveBeenCalled();
});

it("does not submit when the confirm dialog is cancelled", () => {
renderActions([confirmAction]);

fireEvent.click(screen.getByRole("button", { name: "Delete" }));
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));

expect(mutate).not.toHaveBeenCalled();
expect(screen.queryByText("Delete this?")).toBeNull();
});

it("submits with the slot's blockId and baseRevision once confirmed", () => {
renderActions([confirmAction], { surfaceRevision: 7 });

fireEvent.click(screen.getByRole("button", { name: "Delete" }));
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));

expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({
kind: "action",
blockId: "footer",
actionId: "delete",
baseRevision: 7,
}),
expect.any(Object)
);
expect(screen.queryByText("Delete this?")).toBeNull();
});

it("submits immediately for an action with no confirm", () => {
renderActions([{ id: "go", label: "Go", intent: "go" }]);

fireEvent.click(screen.getByRole("button", { name: "Go" }));

expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({ actionId: "go" }),
expect.any(Object)
);
});
});

describe("SlotActions readOnly and durable state", () => {
it("renders the main action natively disabled when the slot is read-only", () => {
renderActions([{ id: "go", label: "Go", intent: "go" }], {
readOnly: true,
});

expect(
screen.getByRole("button", { name: "Go" }).hasAttribute("disabled")
).toBe(true);
});

it("shows the pending caption and locks the button for a claimed durable action", () => {
const interactions = indexInteractions([
{
id: "ix_1",
tabRevision: 1,
blockId: "footer",
actionId: "go",
kind: "action",
status: "claimed",
createdAt: "2026-01-01T00:00:00.000Z",
},
]);
renderActions([{ id: "go", label: "Go", intent: "go" }], { interactions });

expect(
screen.getByRole("button", { name: "Go" }).hasAttribute("disabled")
).toBe(true);
expect(
screen.getByTestId("interaction-status-caption").textContent
).toContain("In progress");
});

it("shows the authored disabledReason for a disabled action with no interaction to report", () => {
renderActions([
{
id: "go",
label: "Go",
intent: "go",
disabled: true,
disabledReason: "Waiting on approval",
},
]);

expect(screen.getByText("Waiting on approval")).toBeTruthy();
// Authored-disabled stays focusable (aria-disabled), unlike a native lock.
const button = screen.getByRole("button", { name: "Go" });
expect(button.hasAttribute("disabled")).toBe(false);
expect(button.getAttribute("aria-disabled")).toBe("true");
});
});
Loading