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>
302 lines
10 KiB
TypeScript
302 lines
10 KiB
TypeScript
/**
|
||
* 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("");
|
||
});
|
||
});
|