feat(FN-4015): classify and recover from stale module-resolution incidents

Merges FN-4015 to add stale module-resolution incident detection and self-healing in the engine (`transient-error-detector.ts`, `self-healing.ts`) with mobile regression coverage for the shared `AgentErrorDetailsModal` layout.

Fusion-Task-Id: FN-4015
This commit is contained in:
Fusion
2026-05-11 11:59:07 -07:00
committed by gsxdsm
parent a8ef54c7f9
commit 42087ddfeb
8 changed files with 301 additions and 15 deletions

View File

@@ -0,0 +1,68 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AgentErrorDetailsModal, AgentErrorIndicator } from "../AgentErrorDetailsModal";
const issueContext = {
surface: "AgentsView",
agentId: "agent-1",
agentName: "Test Agent",
agentState: "error",
runId: "run-1",
taskId: "FN-1",
timestamp: "2026-01-01T00:00:00.000Z",
};
describe("AgentErrorDetailsModal", () => {
const originalClipboard = navigator.clipboard;
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
beforeEach(() => {
Object.defineProperty(navigator, "clipboard", {
value: { writeText: vi.fn().mockResolvedValue(undefined) },
configurable: true,
});
openSpy.mockClear();
});
afterEach(() => {
Object.defineProperty(navigator, "clipboard", { value: originalClipboard, configurable: true });
});
it("does not render when closed", () => {
const { container } = render(<AgentErrorDetailsModal open={false} onClose={vi.fn()} errorText="boom" issueContext={issueContext} />);
expect(container).toBeEmptyDOMElement();
});
it("renders long error text in scrollable error region", () => {
render(<AgentErrorDetailsModal open={true} onClose={vi.fn()} errorText={"stderr\n".repeat(200)} issueContext={issueContext} />);
const errorRegion = document.querySelector(".agent-error-modal__error");
expect(errorRegion).toBeInTheDocument();
expect(errorRegion).toHaveTextContent("stderr");
});
it("copies error text", async () => {
const user = userEvent.setup();
render(<AgentErrorDetailsModal open={true} onClose={vi.fn()} errorText="copy me" issueContext={issueContext} />);
await user.click(screen.getByRole("button", { name: "Copy error to clipboard" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Copied error to clipboard" })).toBeInTheDocument();
});
});
it("opens github report link", async () => {
const user = userEvent.setup();
render(<AgentErrorDetailsModal open={true} onClose={vi.fn()} errorText="report me" issueContext={issueContext} />);
await user.click(screen.getByRole("link", { name: /report on github/i }));
expect(openSpy).toHaveBeenCalledTimes(1);
expect(openSpy.mock.calls[0]?.[0]).toContain("https://github.com/Runfusion/Fusion/issues/new?");
});
it("AgentErrorIndicator opens shared modal", async () => {
const user = userEvent.setup();
render(<AgentErrorIndicator errorText="indicator error" issueContext={issueContext} />);
await user.click(screen.getByRole("button", { name: "Open error details" }));
expect(screen.getByRole("dialog", { name: "Agent error details" })).toBeInTheDocument();
});
});

View File

@@ -394,14 +394,15 @@ describe("agent modal mobile CSS structure", () => {
});
describe("AgentErrorDetailsModal", () => {
it("mobile rules constrain modal to viewport height", () => {
it("mobile rules let modal fill the fullscreen container without double viewport clipping", () => {
const styles = readStyles();
const modalRuleMatch = styles.match(/@media \(max-width: 768px\)[\s\S]*?\.agent-error-modal\s*\{[^}]+\}/);
const modalRuleMatch = styles.match(/@media\s*\(max-width:\s*768px\)\s*\{\s*\.agent-error-modal\s*\{[^}]+\}/);
expect(modalRuleMatch).toBeTruthy();
const modalRule = modalRuleMatch![0];
expect(modalRule).toContain("height: 100dvh");
expect(modalRule).toContain("max-height: 100dvh");
expect(modalRule).toContain("height: 100%");
expect(modalRule).toContain("max-height: 100%");
expect(modalRule).toContain("min-height: 0");
expect(modalRule).toContain("width: 100%");
expect(modalRule).toContain("max-width: 100%");
});
@@ -414,6 +415,7 @@ describe("agent modal mobile CSS structure", () => {
expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.agent-error-modal__error\s*\{[^}]*max-height:\s*none;[^}]*\}/);
expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.agent-error-modal__error\s*\{[^}]*-webkit-overflow-scrolling:\s*touch;[^}]*\}/);
expect(styles).toMatch(/@media \(max-width: 768px\)[\s\S]*?\.agent-error-modal__error\s*\{[^}]*overscroll-behavior:\s*contain;[^}]*\}/);
});
});

View File

@@ -298,22 +298,26 @@ describe("core modals mobile css coverage", () => {
expect(fullscreenBlockMatch![0]).toContain("max-height: unset");
});
it("AgentErrorDetailsModal: mobile uses viewport-sized modal with inner scrolling log region", () => {
it("AgentErrorDetailsModal: mobile fills fullscreen container and keeps inner scrolling log region", () => {
const css = loadAllAppCss();
const mobileBlock = getMainMobileBlock(css);
const modalRuleMatch = mobileBlock.match(/\.agent-error-modal\s*\{[^}]+\}/s);
expect(modalRuleMatch).not.toBeNull();
expect(modalRuleMatch![0]).toContain("height: 100dvh");
expect(modalRuleMatch![0]).toContain("max-height: 100dvh");
expect(modalRuleMatch![0]).toContain("height: 100%");
expect(modalRuleMatch![0]).toContain("max-height: 100%");
expect(modalRuleMatch![0]).toContain("min-height: 0");
const contentRuleMatch = css.match(/\.agent-error-modal__content\s*\{[^}]+\}/s);
expect(contentRuleMatch).not.toBeNull();
expect(contentRuleMatch![0]).toContain("overflow: hidden");
expect(contentRuleMatch![0]).toContain("display: flex");
const errorRuleMatch = mobileBlock.match(/\.agent-error-modal__error\s*\{[^}]+\}/s);
expect(errorRuleMatch).not.toBeNull();
expect(errorRuleMatch![0]).toContain("max-height: none");
expect(errorRuleMatch![0]).toContain("-webkit-overflow-scrolling: touch");
expect(errorRuleMatch![0]).toContain("overscroll-behavior: contain");
});
it("NewTaskModal: quick fields buttons meet 36px touch target on mobile", () => {