Files
PaperClipAI/ui/src/components/ExecutionBlockerNotice.test.tsx
T
DottaandPaperclip a80eec1164 fix(recovery): continue Retry after completed file preservation (#18845)
Reuse completed file preservation for task Retry and show bounded admission explanations, including newer saved messages and identifier routes.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-11-03 16:01:27 -06:00

97 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TaskInterruptionPresentation } from "@paperclipai/shared";
import { ExecutionBlockerNotice } from "./ExecutionBlockerNotice";
const failed: TaskInterruptionPresentation = {
intentId: "intent", responsibility: "system", state: "failed", nextRetryAt: null,
canRetry: true, userAction: null, sourceRunIds: ["source"], observedRunId: "source",
};
describe("ordinary task interruption", () => {
let root: Root;
let container: HTMLDivElement;
const retry = vi.fn();
beforeEach(() => {
retry.mockReset(); container = document.createElement("div"); document.body.append(container);
root = createRoot(container);
});
afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.useRealTimers(); });
const render = async (presentation = failed, options: { canRetry?: boolean; optimistic?: boolean; takingLonger?: boolean; error?: Error } = {}) => {
await act(async () => root.render(<ExecutionBlockerNotice presentation={presentation} canRetry onRetry={retry} {...options} />));
};
it("offers one Retry without delegating infrastructure chores", async () => {
await render();
expect(container.textContent).toContain("Couldn’t finish this attempt. Your message is saved.");
expect(container.querySelectorAll("button")).toHaveLength(1);
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
expect(retry).toHaveBeenCalledOnce();
expect(container.querySelector("a, details, input, textarea")).toBeNull();
expect(container.textContent).not.toMatch(/service attention|process|receipt|workspace/i);
});
it("shows persisted backoff with a usable Retry", async () => {
vi.useFakeTimers(); vi.setSystemTime(new Date("2026-10-20T00:00:00Z"));
await render({ ...failed, state: "retry_scheduled", nextRetryAt: "2026-10-20T00:00:30Z" });
expect(container.textContent).toContain("Trying again in 30s");
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
expect(container.textContent).toContain("Trying again in 25s");
expect(container.querySelector("button")?.textContent).toBe("Retry");
});
it.each(["working", "resolved"] as const)("does not duplicate the thread indicator for %s", async state => {
await render({ ...failed, state }); expect(container.textContent).toBe("");
});
it("leaves genuine user decisions to their specific action surface", async () => {
await render({ ...failed, responsibility: "user", state: "waiting_for_user", userAction: "approval", canRetry: false });
expect(container.textContent).toBe("");
});
it("does not give read-only users a mutation", async () => {
await render(failed, { canRetry: false }); expect(container.querySelector("button")).toBeNull();
});
it("removes the stale failure immediately while admission is pending", async () => {
await render(failed, { optimistic: true }); expect(container.textContent).toBe("");
await render(failed, { optimistic: true, takingLonger: true });
expect(container.textContent).toContain("Taking longer than usual…");
});
it("shows a definitive admission rejection instead of silently dropping Retry", async () => {
await render(failed, { error: new Error("Your task access changed. Refresh the task.") });
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Your task access changed.");
expect(container.querySelectorAll("button")).toHaveLength(1);
});
it("shows workspace preparation failures as plain text while keeping retry permissions", async () => {
const workspaceFailure = { message: 'Could not prepare the project folder <img src=x onerror=alert(1)>.' };
await render({ ...failed, workspaceFailure }, { canRetry: false });
expect(container.textContent).toContain("Couldn’t prepare the task folders.");
expect(container.textContent).toContain(workspaceFailure.message);
expect(container.querySelector("img, button")).toBeNull();
await render({ ...failed, workspaceFailure });
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
expect(retry).toHaveBeenCalledOnce();
await render({ ...failed, workspaceFailure, state: "working" });
expect(container.textContent).toBe("");
});
it("shows why an accepted Retry is waiting and keeps another attempt available", async () => {
const retryWait = { reason: "remote_cleanup", message: "Retry is waiting for the previous environment to stop. Your message is saved." };
await render({ ...failed, retryWait });
expect(container.textContent).toContain(retryWait.message);
expect(container.textContent).not.toContain("Couldn’t finish this attempt.");
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
expect(retry).toHaveBeenCalledOnce();
await render({ ...failed, retryWait }, { canRetry: false });
expect(container.textContent).toContain(retryWait.message);
expect(container.querySelector("button")).toBeNull();
});
it("shows an unknown provider's original explanation as text and preserves read-only access", async () => {
const providerFailure = { schema: "paperclip.provider-failure.v1" as const, provider: "new-provider", phase: "startup" as const,
category: "unknown" as const, retryability: "unknown" as const, httpStatus: 429,
message: "Upstream failure <img src=x onerror=alert(1)>" };
await render({ ...failed, providerFailure }, { canRetry: false });
expect(container.textContent).toContain(providerFailure.message);
expect(container.textContent).toContain("Failed before work started");
expect(container.textContent).toContain("HTTP 429");
expect(container.querySelector("img, button")).toBeNull();
await render({ ...failed, providerFailure, state: "resolved" });
expect(container.textContent).toBe("");
});
});