Files
fusion/packages/dashboard/app/components/__tests__/ScheduleStepsEditor.test.tsx
gsxdsm 3031d05a68 fix(dashboard): clear all 661 test-file type errors
Production typecheck (tsconfig.json + tsconfig.app.json) was already
clean, but a third config that includes test files surfaced 661 errors
across 60+ test files — accumulated drift between mock fixtures and
production types. Six parallel typescript-pro agents fixed every one
without touching production code.

Per-scope before/after (errors → 0):
  ChatView                                                     183
  Mailbox + Agent suite (5 files)                              156
  Task / Modal suite (6 files)                                 127
  App + small components (12 files)                             96
  Hooks + api/auth (8 files)                                    48
  Long tail (32 files)                                          51
  -----------------------------------------------------------------
  Total                                                        661

Major fix categories:
- Untyped state objects inferring `never[]` / `null` literals (root
  cause of ~120 errors in ChatView alone — added a single
  `UseChatReturn` annotation)
- Mock objects missing fields that became required: `WorkflowStep.mode`,
  `ChatMessage.thinkingOutput / metadata`, `ChatSession.projectId`,
  `Task.log`, `ProjectHealth` fields, `PtyTerminalSessionInfo.createdAt`,
  `Agent.metadata`, `InboxResponse.total`, etc.
- Mock objects with stale fields that no longer exist:
  `AgentBudgetStatus.budgetPeriod`, `truncated` on log responses,
  `OutboxResponse.unreadCount`, `MergeResult.source/target/details`
- Modal props that became required (e.g. `PlanningModeModal.onTasksCreated`)
- String literals not in narrowed unions (`Column`, `WorkflowStepPhase`,
  `InsightStatus`, `AgentLogType`, etc.)
- `querySelector` returning `Element` cast to `HTMLElement` for
  `@testing-library/react`'s `within()`
- Vitest mock typing: `.mock.calls` access needing `vi.mocked(...)`,
  zero-param tuple handling, generic `vi.fn(() => [])` inferring
  `never[]`

Helpers introduced in test files (no shared infra):
- `makeSettings(overrides)` in ModelSelectorTab.test.tsx
- `makePromptOverrides(overrides)` in AgentPromptsManager.test.tsx
- `FileBrowserTestOverrides` type alias in FileBrowser.test.tsx
- `makeInboxResponse / makeOutboxResponse` in MailboxView.test.tsx

Verification:
- tsc -p tsconfig.json:        exit 0
- tsc -p tsconfig.app.json:    exit 0
- tsc -p tsconfig.test-check.json (new — includes test files): exit 0
- vitest run:                  9639 / 9641 (2 pre-existing failures
                               in terminal-mobile-keyboard-layout.test.ts
                               unrelated to this work; verified via
                               `git stash` + run on clean HEAD)

Adds packages/dashboard/tsconfig.test-check.json to keep this regression
guard available locally — same as tsconfig.app.json minus the test
exclude.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 19:59:52 -07:00

487 lines
20 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type ComponentProps, useState } from "react";
import { ScheduleStepsEditor } from "../ScheduleStepsEditor";
import type { AutomationStep } from "@fusion/core";
type ScheduleStepsEditorProps = ComponentProps<typeof ScheduleStepsEditor>;
// Mock @fusion/core
vi.mock("@fusion/core", () => ({}));
// Mock lucide-react
vi.mock("lucide-react", () => ({
Plus: () => <span data-testid="icon-plus">+</span>,
Trash2: () => <span data-testid="icon-trash">🗑</span>,
ChevronUp: () => <span data-testid="icon-up"></span>,
ChevronDown: () => <span data-testid="icon-down"></span>,
Pencil: () => <span data-testid="icon-pencil"></span>,
GripVertical: () => <span data-testid="icon-grip"></span>,
Terminal: () => <span data-testid="icon-terminal">$</span>,
Sparkles: () => <span data-testid="icon-sparkles"></span>,
ListPlus: () => <span data-testid="icon-list-plus">📋</span>,
}));
// Mock api - provide models synchronously for immediate availability
const mockModels = [
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet", reasoning: false, contextWindow: 200000 },
];
vi.mock("../api", () => ({
fetchModels: vi.fn(() => Promise.resolve({
models: mockModels,
favoriteProviders: [],
favoriteModels: [],
})),
}));
// Mock CustomModelDropdown
vi.mock("../CustomModelDropdown", () => ({
CustomModelDropdown: (props: any) => {
// Store the last value for debugging
(window as any).__lastModelDropdownProps = props;
return (
<select
data-testid="model-dropdown"
value={props.value ?? ""}
onChange={(e) => props.onChange?.(e.target.value)}
disabled={props.disabled}
data-value={props.value}
>
<option value="">Use default</option>
{props.models?.map((m: any) => (
<option key={`${m.provider}/${m.id}`} value={`${m.provider}/${m.id}`}>
{m.name}
</option>
))}
</select>
);
},
}));
// Mock crypto.randomUUID for deterministic tests
let uuidCounter = 0;
vi.stubGlobal("crypto", {
randomUUID: () => `test-uuid-${++uuidCounter}`,
});
function makeStep(overrides: Partial<AutomationStep> = {}): AutomationStep {
return {
id: `step-${++uuidCounter}`,
type: "command",
name: "Test Step",
command: "echo hello",
...overrides,
};
}
describe("ScheduleStepsEditor", () => {
const onChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
uuidCounter = 0;
});
describe("step addition", () => {
it("renders add buttons for command and AI prompt", () => {
render(<ScheduleStepsEditor steps={[]} onChange={onChange} />);
expect(screen.getByText("Add Command Step")).toBeDefined();
expect(screen.getByText("Add AI Prompt Step")).toBeDefined();
expect(screen.getByText("Add Create Task Step")).toBeDefined();
});
it("adds a command step when clicking Add Command Step", () => {
render(<ScheduleStepsEditor steps={[]} onChange={onChange} />);
fireEvent.click(screen.getByText("Add Command Step"));
expect(onChange).toHaveBeenCalledTimes(1);
const newSteps = onChange.mock.calls[0][0] as AutomationStep[];
expect(newSteps).toHaveLength(1);
expect(newSteps[0].type).toBe("command");
expect(newSteps[0].name).toBe("New Command Step");
});
it("adds an AI prompt step when clicking Add AI Prompt Step", () => {
render(<ScheduleStepsEditor steps={[]} onChange={onChange} />);
fireEvent.click(screen.getByText("Add AI Prompt Step"));
expect(onChange).toHaveBeenCalledTimes(1);
const newSteps = onChange.mock.calls[0][0] as AutomationStep[];
expect(newSteps).toHaveLength(1);
expect(newSteps[0].type).toBe("ai-prompt");
expect(newSteps[0].name).toBe("New AI Prompt Step");
});
it("adds a create-task step when clicking Add Create Task Step", () => {
render(<ScheduleStepsEditor steps={[]} onChange={onChange} />);
fireEvent.click(screen.getByText("Add Create Task Step"));
expect(onChange).toHaveBeenCalledTimes(1);
const newSteps = onChange.mock.calls[0][0] as AutomationStep[];
expect(newSteps).toHaveLength(1);
expect(newSteps[0].type).toBe("create-task");
expect(newSteps[0].name).toBe("New Create Task Step");
expect(newSteps[0].taskDescription).toBe("");
expect(newSteps[0].taskColumn).toBe("triage");
});
it("appends to existing steps", () => {
const existing = [makeStep({ name: "Existing" })];
render(<ScheduleStepsEditor steps={existing} onChange={onChange} />);
fireEvent.click(screen.getByText("Add Command Step"));
const newSteps = onChange.mock.calls[0][0] as AutomationStep[];
expect(newSteps).toHaveLength(2);
expect(newSteps[0].name).toBe("Existing");
});
});
describe("step deletion", () => {
it("removes a step when delete button is clicked", () => {
const steps = [
makeStep({ id: "s1", name: "First" }),
makeStep({ id: "s2", name: "Second" }),
];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
const deleteButtons = screen.getAllByTitle("Delete");
fireEvent.click(deleteButtons[0]);
const newSteps = onChange.mock.calls[0][0] as AutomationStep[];
expect(newSteps).toHaveLength(1);
expect(newSteps[0].name).toBe("Second");
});
});
describe("step reordering", () => {
it("moves a step up", () => {
const steps = [
makeStep({ id: "s1", name: "First" }),
makeStep({ id: "s2", name: "Second" }),
];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
// Click "Move up" on the second step
const moveUpButtons = screen.getAllByTitle("Move up");
fireEvent.click(moveUpButtons[1]); // second step's move up button
const newSteps = onChange.mock.calls[0][0] as AutomationStep[];
expect(newSteps[0].name).toBe("Second");
expect(newSteps[1].name).toBe("First");
});
it("moves a step down", () => {
const steps = [
makeStep({ id: "s1", name: "First" }),
makeStep({ id: "s2", name: "Second" }),
];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
// Click "Move down" on the first step
const moveDownButtons = screen.getAllByTitle("Move down");
fireEvent.click(moveDownButtons[0]); // first step's move down button
const newSteps = onChange.mock.calls[0][0] as AutomationStep[];
expect(newSteps[0].name).toBe("Second");
expect(newSteps[1].name).toBe("First");
});
it("disables Move Up on the first step", () => {
const steps = [makeStep({ id: "s1", name: "Only" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
const moveUpBtn = screen.getByLabelText("Move Only up");
expect(moveUpBtn.hasAttribute("disabled")).toBe(true);
});
it("disables Move Down on the last step", () => {
const steps = [makeStep({ id: "s1", name: "Only" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
const moveDownBtn = screen.getByLabelText("Move Only down");
expect(moveDownBtn.hasAttribute("disabled")).toBe(true);
});
});
describe("step editor state", () => {
function StatefulEditor(props: Omit<ScheduleStepsEditorProps, 'onChange'>) {
const [steps, setSteps] = useState<AutomationStep[]>(props.steps);
return <ScheduleStepsEditor steps={steps} onChange={setSteps} />;
}
it("opens step editor automatically when adding a new step", () => {
render(<StatefulEditor steps={[]} />);
fireEvent.click(screen.getByText("Add Command Step"));
// After adding, the editor should be open (showing Save Step button)
expect(screen.getByText("Save Step")).toBeDefined();
});
it("notifies parent when editing state changes", () => {
const onEditingChange = vi.fn();
function StatefulEditorWithCallback(props: Omit<ScheduleStepsEditorProps, 'onChange'>) {
const [steps, setSteps] = useState<AutomationStep[]>(props.steps);
return <ScheduleStepsEditor steps={steps} onChange={setSteps} onEditingChange={onEditingChange} />;
}
render(<StatefulEditorWithCallback steps={[]} />);
// Should be called with true when opening editor
fireEvent.click(screen.getByText("Add Command Step"));
expect(onEditingChange).toHaveBeenLastCalledWith(true);
// Should be called with false when canceling
fireEvent.click(screen.getByText("Cancel"));
expect(onEditingChange).toHaveBeenLastCalledWith(false);
});
it("opens step editor for existing steps when edit is clicked", () => {
const steps = [makeStep({ id: "s1", name: "Build" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit Build"));
expect(screen.getByText("Save Step")).toBeDefined();
expect(screen.getByLabelText("Step Name")).toHaveProperty("value", "Build");
});
it("closes editor on cancel", () => {
const steps = [makeStep({ id: "s1", name: "Build" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit Build"));
expect(screen.getByText("Save Step")).toBeDefined();
fireEvent.click(screen.getByText("Cancel"));
// Editor should be closed; step card should be visible again
expect(screen.queryByText("Save Step")).toBeNull();
expect(screen.getByText("Build")).toBeDefined();
});
});
describe("form validation", () => {
it("shows error when step name is empty", () => {
const steps = [makeStep({ id: "s1", name: "Build" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit Build"));
// Clear the name
fireEvent.change(screen.getByLabelText("Step Name"), { target: { value: "" } });
fireEvent.click(screen.getByText("Save Step"));
expect(screen.getByText("Step name is required")).toBeDefined();
expect(onChange).not.toHaveBeenCalled();
});
it("shows error when command step has no command", () => {
const steps = [makeStep({ id: "s1", name: "Build", command: "echo test" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit Build"));
// Clear the command
fireEvent.change(screen.getByDisplayValue("echo test"), { target: { value: "" } });
fireEvent.click(screen.getByText("Save Step"));
expect(screen.getByText("Command is required")).toBeDefined();
});
it("shows error when create-task step has no task description", () => {
const steps = [makeStep({ id: "s1", type: "create-task", name: "Create Task", taskDescription: "Some description" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit Create Task"));
// Clear the task description
const descField = screen.getByLabelText("Task Description *");
fireEvent.change(descField, { target: { value: "" } });
fireEvent.click(screen.getByText("Save Step"));
expect(screen.getByText("Task description is required")).toBeDefined();
expect(onChange).not.toHaveBeenCalled();
});
it("allows saving create-task step with all fields filled", async () => {
const steps = [makeStep({ id: "s1", type: "create-task", name: "Create Task", taskDescription: "", taskColumn: "triage" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit Create Task"));
// Fill in all fields
fireEvent.change(screen.getByLabelText("Task Title (optional)"), { target: { value: "Weekly Review" } });
fireEvent.change(screen.getByLabelText("Task Description *"), { target: { value: "Check dependencies" } });
fireEvent.click(screen.getByText("Save Step"));
expect(onChange).toHaveBeenCalledTimes(1);
const savedStep = onChange.mock.calls[0][0][0] as AutomationStep;
expect(savedStep.taskTitle).toBe("Weekly Review");
expect(savedStep.taskDescription).toBe("Check dependencies");
expect(savedStep.taskColumn).toBe("triage");
});
});
describe("empty state", () => {
it("shows empty state message when no steps", () => {
render(<ScheduleStepsEditor steps={[]} onChange={onChange} />);
expect(screen.getByText(/No steps added yet/)).toBeDefined();
});
it("does not show empty state when steps exist", () => {
const steps = [makeStep()];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
expect(screen.queryByText(/No steps added yet/)).toBeNull();
});
});
describe("step display", () => {
it("shows step index numbers", () => {
const steps = [
makeStep({ id: "s1", name: "First" }),
makeStep({ id: "s2", name: "Second" }),
];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
expect(screen.getByText("1")).toBeDefined();
expect(screen.getByText("2")).toBeDefined();
});
it("shows step names", () => {
const steps = [makeStep({ id: "s1", name: "Build project" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
expect(screen.getByText("Build project")).toBeDefined();
});
it("shows continueOnFailure flag", () => {
const steps = [makeStep({ id: "s1", name: "Build", continueOnFailure: true })];
const { container } = render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
const flag = container.querySelector(".step-card-flag");
expect(flag).not.toBeNull();
expect(flag?.textContent).toBe("⚡");
});
it("shows step count in header", () => {
const steps = [makeStep(), makeStep({ id: "s2" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
expect(screen.getByText("Steps (2)")).toBeDefined();
});
});
describe("model selection", () => {
it("shows model dropdown for AI prompt step type", async () => {
const steps = [makeStep({ id: "s1", name: "AI Step", type: "ai-prompt", prompt: "Test prompt" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
// Click edit to open the step editor
fireEvent.click(screen.getByLabelText("Edit AI Step"));
// Wait for models to load and dropdown to appear
await waitFor(() => expect(screen.getByTestId("model-dropdown")).toBeDefined());
expect(screen.getByTestId("model-dropdown")).toBeDefined();
});
it("does not show model dropdown for command step type", () => {
const steps = [makeStep({ id: "s1", name: "Command Step" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit Command Step"));
expect(screen.queryByTestId("model-dropdown")).toBeNull();
});
it("pre-populates model dropdown when editing step with existing model", async () => {
const steps = [makeStep({
id: "s1",
type: "ai-prompt",
name: "Analyze",
prompt: "Analyze this",
modelProvider: "openai",
modelId: "gpt-4o"
})];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit Analyze"));
// Wait for models to load and dropdown to appear
await waitFor(() => expect(screen.getByTestId("model-dropdown")).toBeDefined());
const dropdown = screen.getByTestId("model-dropdown") as HTMLSelectElement;
// Use data-value attribute to verify the passed value since React controlled select
// DOM property may not sync immediately with the prop value
expect(dropdown.getAttribute("data-value")).toBe("openai/gpt-4o");
});
it("model dropdown receives onChange callback", async () => {
const steps = [makeStep({ id: "s1", type: "ai-prompt", name: "AI Step", prompt: "Test prompt" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit AI Step"));
// Wait for dropdown to appear
await waitFor(() => expect(screen.getByTestId("model-dropdown")).toBeDefined());
// Get the mock component props to verify onChange is passed correctly
const lastProps = (window as any).__lastModelDropdownProps;
expect(lastProps).toBeDefined();
expect(typeof lastProps.onChange).toBe("function");
// The onChange should be a function that accepts a value string
// We can't fully test the React state update in this mock setup,
// but we can verify the callback is properly wired
});
it("shows model dropdown for create-task step type", async () => {
const steps = [makeStep({ id: "s1", type: "create-task", name: "Create Task", taskDescription: "Test description" })];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit Create Task"));
// Wait for models to load and dropdown to appear
await waitFor(() => expect(screen.getByTestId("model-dropdown")).toBeDefined());
expect(screen.getByTestId("model-dropdown")).toBeDefined();
});
it("pre-populates model dropdown when editing create-task step with existing model", async () => {
const steps = [makeStep({
id: "s1",
type: "create-task",
name: "Create Task",
taskDescription: "Test description",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5"
})];
render(<ScheduleStepsEditor steps={steps} onChange={onChange} />);
fireEvent.click(screen.getByLabelText("Edit Create Task"));
// Wait for models to load and dropdown to appear
await waitFor(() => expect(screen.getByTestId("model-dropdown")).toBeDefined());
const dropdown = screen.getByTestId("model-dropdown") as HTMLSelectElement;
expect(dropdown.getAttribute("data-value")).toBe("anthropic/claude-sonnet-4-5");
});
});
describe("ID generation fallback", () => {
it("adds steps when crypto.randomUUID is unavailable", () => {
// Remove crypto.randomUUID to simulate non-secure context
vi.stubGlobal("crypto", {});
render(<ScheduleStepsEditor steps={[]} onChange={onChange} />);
fireEvent.click(screen.getByText("Add Command Step"));
expect(onChange).toHaveBeenCalledTimes(1);
const newSteps = onChange.mock.calls[0][0] as AutomationStep[];
expect(newSteps).toHaveLength(1);
expect(newSteps[0].type).toBe("command");
expect(newSteps[0].id).toBeTruthy();
});
it("adds AI prompt steps when crypto.randomUUID is unavailable", () => {
vi.stubGlobal("crypto", {});
render(<ScheduleStepsEditor steps={[]} onChange={onChange} />);
fireEvent.click(screen.getByText("Add AI Prompt Step"));
expect(onChange).toHaveBeenCalledTimes(1);
const newSteps = onChange.mock.calls[0][0] as AutomationStep[];
expect(newSteps).toHaveLength(1);
expect(newSteps[0].type).toBe("ai-prompt");
expect(newSteps[0].id).toBeTruthy();
});
it("adds steps when crypto is entirely undefined", () => {
vi.stubGlobal("crypto", undefined);
render(<ScheduleStepsEditor steps={[]} onChange={onChange} />);
fireEvent.click(screen.getByText("Add Command Step"));
expect(onChange).toHaveBeenCalledTimes(1);
const newSteps = onChange.mock.calls[0][0] as AutomationStep[];
expect(newSteps).toHaveLength(1);
expect(newSteps[0].id).toMatch(/^step-/);
});
it("enters edit mode for new step using fallback IDs", () => {
vi.stubGlobal("crypto", {});
function StatefulEditor() {
const [steps, setSteps] = useState<AutomationStep[]>([]);
return <ScheduleStepsEditor steps={steps} onChange={setSteps} />;
}
render(<StatefulEditor />);
fireEvent.click(screen.getByText("Add Command Step"));
// Editor should open immediately for the new step
expect(screen.getByText("Save Step")).toBeDefined();
});
});
});