Files
fusion/packages/dashboard/app/components/__tests__/PluginManager.install-browse.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

302 lines
10 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.
/**
* Regression tests for the browse-driven plugin install workflow.
*
* Verifies that:
* 1. DirectoryPicker selection updates the install source field
* 2. Install sends the expected { path } payload
* 3. Negative paths: empty path, install failure
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, within, fireEvent, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { PluginInstallation } from "@fusion/core";
// ── Mock API ──────────────────────────────────────────────────────
vi.mock("../../api", () => ({
fetchPlugins: vi.fn(() => Promise.resolve([])),
installPlugin: vi.fn(() =>
Promise.resolve({
id: "browsed-plugin",
name: "Browsed Plugin",
version: "0.1.0",
state: "installed" as const,
enabled: true,
settings: {},
settingsSchema: {},
}),
),
enablePlugin: vi.fn(() => Promise.resolve({})),
disablePlugin: vi.fn(() => Promise.resolve({})),
uninstallPlugin: vi.fn(() => Promise.resolve()),
fetchPluginSettings: vi.fn(() => Promise.resolve({})),
updatePluginSettings: vi.fn(() => Promise.resolve({})),
reloadPlugin: vi.fn(() => Promise.resolve({})),
browseDirectory: vi.fn(() =>
Promise.resolve({
currentPath: "/home/user/plugins/my-plugin",
parentPath: "/home/user/plugins",
entries: [
{ name: "dist", path: "/home/user/plugins/my-plugin/dist", hasChildren: true },
{ name: "src", path: "/home/user/plugins/my-plugin/src", hasChildren: true },
],
}),
),
}));
import { PluginManager } from "../PluginManager";
import {
fetchPlugins,
installPlugin,
browseDirectory,
} from "../../api";
const addToast = vi.fn();
// ── Shared setup ──────────────────────────────────────────────────
beforeEach(() => {
vi.clearAllMocks();
// Stub EventSource globally
const esInstance = {
url: "",
readyState: 1,
close: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
onerror: null,
onopen: null,
onmessage: null,
};
const MockES = vi.fn(() => esInstance) as unknown as typeof EventSource;
(MockES as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).CONNECTING = 0;
(MockES as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).OPEN = 1;
(MockES as unknown as { CONNECTING: number; OPEN: number; CLOSED: number }).CLOSED = 2;
vi.stubGlobal("EventSource", MockES);
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ── Helpers ───────────────────────────────────────────────────────
/** Open the install form by clicking the header Install button. */
async function openInstallForm() {
const installBtn = screen.getByRole("button", { name: /^Install$/ });
await userEvent.click(installBtn);
}
/** Return the DirectoryPicker input inside the install form. */
function getPathInput(): HTMLInputElement {
return screen.getByPlaceholderText(
"Absolute path to plugin directory or dist folder",
) as HTMLInputElement;
}
// ══════════════════════════════════════════════════════════════════
describe("PluginManager browse-driven install workflow", () => {
it("opens DirectoryPicker browser and selects a directory to set install path", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
// Click "Browse" to open the directory browser
const browseBtn = screen.getByRole("button", {
name: /Browse directories/i,
});
await userEvent.click(browseBtn);
// browseDirectory should have been called
await waitFor(() => expect(browseDirectory).toHaveBeenCalled());
// The browser shows the "Select" button
const selectBtn = screen.getByRole("button", { name: /^Select$/i });
// Clicking Select should copy the currentPath into the input
await userEvent.click(selectBtn);
// The input value should now match the browsed directory
expect(getPathInput().value).toBe("/home/user/plugins/my-plugin");
});
it("sends { path } payload matching the browsed path on install", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
// Simulate browse-and-select by typing a path (DirectoryPicker onChange)
const input = getPathInput();
await userEvent.type(input, "/home/user/plugins/my-plugin");
// Click Install Plugin
const formContainer = input.closest(".plugin-install-form")!;
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
name: /Install Plugin/i,
});
await userEvent.click(installBtn);
await waitFor(() => {
expect(installPlugin).toHaveBeenCalledWith(
{ path: "/home/user/plugins/my-plugin" },
undefined,
);
});
});
it("sends { path } with dist subfolder when user navigates into dist", async () => {
// Override browseDirectory to return a dist-level path
vi.mocked(browseDirectory).mockResolvedValueOnce({
currentPath: "/home/user/plugins/my-plugin/dist",
parentPath: "/home/user/plugins/my-plugin",
entries: [],
});
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
// Directly type a dist path (mimicking what Select would do)
const input = getPathInput();
await userEvent.type(input, "/home/user/plugins/my-plugin/dist");
const formContainer = input.closest(".plugin-install-form")!;
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
name: /Install Plugin/i,
});
await userEvent.click(installBtn);
await waitFor(() => {
expect(installPlugin).toHaveBeenCalledWith(
{ path: "/home/user/plugins/my-plugin/dist" },
undefined,
);
});
});
it("passes projectId when provided", async () => {
render(<PluginManager addToast={addToast} projectId="proj-xyz" />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalledWith("proj-xyz"));
await openInstallForm();
const input = getPathInput();
await userEvent.type(input, "/plugins/example");
const formContainer = input.closest(".plugin-install-form")!;
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
name: /Install Plugin/i,
});
await userEvent.click(installBtn);
await waitFor(() => {
expect(installPlugin).toHaveBeenCalledWith(
{ path: "/plugins/example" },
"proj-xyz",
);
});
});
// ── Negative paths ────────────────────────────────────────────
it("shows error toast when install path is empty", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
// Install Plugin button should be disabled with empty input
const formContainer = getPathInput().closest(".plugin-install-form")!;
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
name: /Install Plugin/i,
});
expect(installBtn).toBeDisabled();
// installPlugin should NOT have been called
expect(installPlugin).not.toHaveBeenCalled();
});
it("shows error toast when installPlugin rejects", async () => {
vi.mocked(installPlugin).mockRejectedValueOnce(
new Error("manifest.json not found"),
);
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
const input = getPathInput();
await userEvent.type(input, "/invalid/path");
const formContainer = input.closest(".plugin-install-form")!;
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
name: /Install Plugin/i,
});
await userEvent.click(installBtn);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
expect.stringContaining("manifest.json not found"),
"error",
);
});
});
it("resets install path and hides form on successful install", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
const input = getPathInput();
await userEvent.type(input, "/path/to/plugin");
const formContainer = input.closest(".plugin-install-form")!;
const installBtn = within(formContainer as HTMLElement).getByRole("button", {
name: /Install Plugin/i,
});
await userEvent.click(installBtn);
await waitFor(() => {
expect(addToast).toHaveBeenCalledWith(
"Plugin installed successfully",
"success",
);
});
// Install form should be gone
expect(
screen.queryByPlaceholderText(
"Absolute path to plugin directory or dist folder",
),
).toBeNull();
});
it("cancels install form and clears path", async () => {
render(<PluginManager addToast={addToast} />);
await waitFor(() => expect(fetchPlugins).toHaveBeenCalled());
await openInstallForm();
const input = getPathInput();
await userEvent.type(input, "/some/path");
const cancelBtn = screen.getByRole("button", { name: /Cancel/i });
await userEvent.click(cancelBtn);
// Form should be gone
expect(
screen.queryByPlaceholderText(
"Absolute path to plugin directory or dist folder",
),
).toBeNull();
// Re-opening should show empty input
await openInstallForm();
expect(getPathInput().value).toBe("");
});
});