Files
PaperClipAI/ui/src/components/task-chat/TaskChatSystemNotice.test.tsx
T
DottaandPaperclip b11010b0d4 Simplify failure banners and show only the latest error (#16982)
Remove dashed failure dividers and show only the latest adjacent run or recovery error. Preserve permitted retries, pending state and unrelated notice boundaries. Add production-component Storybook examples and browser regression coverage.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-20 14:29:39 -05:00

341 lines
12 KiB
TypeScript

// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeProvider } from "@/context/ThemeContext";
import { TaskChatSystemNotice } from "./TaskChatSystemNotice";
import type { TaskChatMessageItem } from "./task-chat-model";
describe("TaskChatSystemNotice (PAP-443)", () => {
let container: HTMLDivElement;
let root: Root | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
flushSync(() => root?.unmount());
root = null;
container.remove();
});
const recoveryBody =
"Paperclip stopped before dispatching the adapter because required secret/env bindings are missing. " +
"Latest retry failure: `configuration_incomplete`. Moving it to `blocked` with a source-scoped recovery action.";
function renderNotice(
overrides: Partial<TaskChatMessageItem> = {},
props: {
onTryAgainNoLiveExecutionPath?: () => Promise<void> | void;
tryAgainNoLiveExecutionPathPending?: boolean;
} = {},
) {
const item: TaskChatMessageItem = {
id: "sys-1",
kind: "message",
author: "system",
text: recoveryBody,
createdAtIso: new Date(Date.now() - 5 * 60_000).toISOString(),
...overrides,
};
flushSync(() =>
root!.render(
<ThemeProvider>
<TaskChatSystemNotice item={item} {...props} />
</ThemeProvider>,
),
);
}
function toggleButton() {
return container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-system-notice"] button[aria-expanded]',
)!;
}
it("collapses to a humanized one-liner with relative time and hides the raw body", () => {
renderNotice();
const button = toggleButton();
expect(button.getAttribute("aria-expanded")).toBe("false");
expect(button.textContent).toContain("Task paused — a secret/config binding is missing");
expect(button.textContent).toContain("5m ago");
expect(container.textContent).not.toContain("source-scoped recovery action");
expect(
container.querySelector('[data-testid="task-chat-system-notice-details"]'),
).toBeNull();
});
it("expands on click to the full markdown body and metadata sections", () => {
renderNotice({
metadata: {
version: 1,
sections: [
{ title: "Failure", rows: [{ type: "code", label: "Code", code: "configuration_incomplete" }] },
],
},
});
flushSync(() => toggleButton().click());
expect(toggleButton().getAttribute("aria-expanded")).toBe("true");
const details = container.querySelector('[data-testid="task-chat-system-notice-details"]');
expect(details).not.toBeNull();
expect(details!.textContent).toContain("required secret/env bindings are missing");
expect(details!.textContent).toContain("Failure");
expect(details!.textContent).toContain("configuration_incomplete");
// Collapses back — presentation-only fold, nothing lost.
flushSync(() => toggleButton().click());
expect(
container.querySelector('[data-testid="task-chat-system-notice-details"]'),
).toBeNull();
});
it("links source-run metadata when the comment carries its run agent", () => {
renderNotice({
metadata: {
version: 1,
sections: [
{
title: "Run",
rows: [{ type: "run_link", label: "Source run", runId: "run-1", agentId: "agent-1", title: "failed" }],
},
],
},
});
flushSync(() => toggleButton().click());
expect(container.querySelector('a[href="/agents/agent-1/runs/run-1"]')).not.toBeNull();
});
it("respects presentation.detailsDefaultOpen", () => {
renderNotice({
presentation: {
kind: "system_notice",
tone: "warning",
title: "Run recovery",
detailsDefaultOpen: true,
},
});
expect(toggleButton().getAttribute("aria-expanded")).toBe("true");
expect(toggleButton().textContent).toContain("Run recovery");
expect(
container.querySelector('[data-testid="task-chat-system-notice-details"]'),
).not.toBeNull();
});
it("shows workspace-ready comments as a compact row with expandable workspace metadata", () => {
renderNotice({
text: [
"## Workspace Ready",
"",
"- Strategy: `git_worktree`",
"- Branch: `fix/workspace-ready-notice`",
"- CWD: `/worktrees/workspace-ready-notice`",
].join("\n"),
presentation: {
kind: "system_notice",
tone: "info",
title: "Workspace ready · fix/workspace-ready-notice",
detailsDefaultOpen: false,
density: "compact",
},
metadata: {
version: 1,
sections: [
{
title: "Workspace",
rows: [
{ type: "key_value", label: "Strategy", value: "git_worktree" },
{
type: "key_value",
label: "Branch",
value: "fix/workspace-ready-notice",
},
{ type: "key_value", label: "CWD", value: "/worktrees/workspace-ready-notice" },
],
},
],
},
});
expect(toggleButton().getAttribute("aria-expanded")).toBe("false");
expect(container.querySelector('[data-testid="task-chat-system-notice"]')?.className).toContain(
"items-start",
);
expect(toggleButton().textContent).toContain(
"Workspace ready · fix/workspace-ready-notice",
);
expect(container.textContent).not.toContain("git_worktree");
flushSync(() => toggleButton().click());
const details = container.querySelector('[data-testid="task-chat-system-notice-details"]');
expect(details?.textContent).toContain("Workspace");
expect(details?.textContent?.match(/git_worktree/g)).toHaveLength(1);
expect(details?.textContent?.match(/fix\/workspace-ready-notice/g)).toHaveLength(1);
expect(details?.textContent).toContain("/worktrees/workspace-ready-notice");
expect(details?.querySelector(".paperclip-markdown")).toBeNull();
});
it("shows Try again while folded and invokes it without expanding the notice", async () => {
const onTryAgain = vi.fn();
renderNotice(
{
text: "Paperclip retried continuation, but it still has no live execution path.",
presentation: {
kind: "system_notice",
tone: "danger",
title: "No live execution path",
detailsDefaultOpen: false,
},
},
{ onTryAgainNoLiveExecutionPath: onTryAgain },
);
const tryAgain = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-no-live-path-try-again"]',
);
expect(tryAgain?.textContent).toBe("Try again");
flushSync(() => tryAgain!.click());
await Promise.resolve();
expect(onTryAgain).toHaveBeenCalledTimes(1);
expect(toggleButton().getAttribute("aria-expanded")).toBe("false");
expect(
container
.querySelector('[data-testid="task-chat-system-notice"]')
?.classList.contains("items-start"),
).toBe(true);
});
it.each([false, true])("preserves retry on a saved Automatic recovery blocked notice (pending: %s)", async (pending) => {
const onTryAgain = vi.fn();
renderNotice({
text: "Paperclip automatically retried continuation for this assigned `in_progress` issue and the retry made progress, but it still has no live execution path. Moving it to `blocked` so it is visible for intervention.",
presentation: { kind: "system_notice", title: "Automatic recovery blocked", tone: "danger", detailsDefaultOpen: false },
metadata: { version: 1, sourceRunId: "run-1", sections: [{ title: "Recovery", rows: [
{ type: "key_value", label: "Recovery action", value: "recovery-action-1" },
] }] },
}, { onTryAgainNoLiveExecutionPath: onTryAgain, tryAgainNoLiveExecutionPathPending: pending });
const retry = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-no-live-path-try-again"]');
expect(retry).not.toBeNull();
expect(retry!.disabled).toBe(pending);
if (!pending) {
flushSync(() => retry!.click());
await Promise.resolve();
expect(onTryAgain).toHaveBeenCalledTimes(1);
}
flushSync(() => toggleButton().click());
expect(container.querySelectorAll('[data-testid="task-chat-no-live-path-try-again"]')).toHaveLength(1);
expect(container.querySelector('[data-testid="task-chat-system-notice-details"] [data-testid="task-chat-no-live-path-try-again"]')).not.toBeNull();
});
it("moves Try again into the expanded notice footer", () => {
renderNotice(
{
text: "Paperclip retried continuation, but it still has no live execution path.",
presentation: {
kind: "system_notice",
tone: "danger",
title: "No live execution path",
detailsDefaultOpen: false,
},
},
{ onTryAgainNoLiveExecutionPath: vi.fn() },
);
flushSync(() => toggleButton().click());
const details = container.querySelector(
'[data-testid="task-chat-system-notice-details"]',
);
expect(
details?.querySelector('[data-testid="task-chat-no-live-path-try-again"]'),
).not.toBeNull();
expect(
container.querySelectorAll('[data-testid="task-chat-no-live-path-try-again"]'),
).toHaveLength(1);
});
it.each([
{ text: "Paperclip retried continuation, but it still has no live execution path.",
presentation: { kind: "system_notice" as const, title: "Child task blocked", tone: "warning" as const, detailsDefaultOpen: false } },
{ text: "Child task has no live execution path. Parent must review its result." },
])("does not attach a parent recovery retry to an unrelated notice: $text", (item) => {
renderNotice(item, { onTryAgainNoLiveExecutionPath: vi.fn() });
expect(container.querySelector('[data-testid="task-chat-no-live-path-try-again"]')).toBeNull();
});
it("shows the pending state and omits Try again from unrelated notices", () => {
renderNotice(
{
text: "Paperclip retried continuation, but it still has no live execution path.",
presentation: {
kind: "system_notice",
tone: "danger",
title: "No live execution path",
detailsDefaultOpen: false,
},
},
{
onTryAgainNoLiveExecutionPath: vi.fn(),
tryAgainNoLiveExecutionPathPending: true,
},
);
const pending = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-no-live-path-try-again"]',
);
expect(pending?.disabled).toBe(true);
expect(pending?.textContent).toBe("Trying again...");
renderNotice(
{
text: recoveryBody,
presentation: {
kind: "system_notice",
tone: "danger",
title: "Configuration incomplete",
detailsDefaultOpen: false,
},
},
{ onTryAgainNoLiveExecutionPath: vi.fn() },
);
expect(
container.querySelector('[data-testid="task-chat-no-live-path-try-again"]'),
).toBeNull();
});
it("keeps workspace-ready events as a compact expandable notice", () => {
renderNotice({
text: "Workspace ready. The isolated worktree is available at `/tmp/paperclip/worktrees/PAP-91`.",
metadata: null,
});
const button = toggleButton();
expect(button.getAttribute("aria-expanded")).toBe("false");
expect(button.textContent).toContain("System update");
expect(button.textContent).toContain("Workspace ready.");
expect(button.querySelector("code")).toBeNull();
expect(container.textContent).not.toContain("/tmp/paperclip/worktrees/PAP-91");
flushSync(() => button.click());
expect(container.textContent).toContain("/tmp/paperclip/worktrees/PAP-91");
});
it("ignores malformed metadata while preserving expandable raw detail", () => {
renderNotice({
text: "Workspace ready. Runtime metadata could not be decoded.",
metadata: { version: 1, sections: "malformed" } as unknown as TaskChatMessageItem["metadata"],
});
expect(() => flushSync(() => toggleButton().click())).not.toThrow();
expect(container.querySelector('[data-testid="task-chat-system-notice-details"]')).not.toBeNull();
expect(container.textContent).toContain("Runtime metadata could not be decoded");
});
});