Files
fusion/packages/dashboard/app/components/__tests__/ProjectCard.test.tsx
gsxdsm bb89e1ec3c FN-7153: relabel project card engine controls
Project dashboard cards now describe engine lifecycle actions directly.

- Change project card Pause/Resume controls to Stop engine/Start engine while preserving existing behavior.
- Replace the pause icon with a stop-square glyph for the stop action.
- Update project card i18n resources, generated resource types, tests, and the release changeset.

Files changed:
 .changeset/fn-7153-stop-engine-label.md            |   7 ++
 packages/dashboard/app/components/ProjectCard.tsx  |  20 ++--
 .../app/components/__tests__/ProjectCard.test.tsx  | 103 ++++++++++++++++++---
 .../__tests__/ProjectThemeTokens.test.tsx          |   1 +
 packages/i18n/locales/en/app.json                  |  10 +-
 packages/i18n/locales/es/app.json                  |  10 +-
 packages/i18n/locales/fr/app.json                  |  10 +-
 packages/i18n/locales/ko/app.json                  |  10 +-
 packages/i18n/locales/zh-CN/app.json               |  10 +-
 packages/i18n/locales/zh-TW/app.json               |  10 +-
 packages/i18n/src/resources.d.ts                   |  74 +++++++++++++--
 11 files changed, 208 insertions(+), 57 deletions(-)

Fusion-Task-Id: FN-7153

Fusion-Task-Lineage: 14a4efe7-7538-425e-9759-ded550438df2

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-06-27 19:54:22 -07:00

682 lines
20 KiB
TypeScript

import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { ProjectCard } from "../ProjectCard";
import type { RegisteredProject, ProjectHealth, ProjectStatus } from "@fusion/core";
// Mock lucide-react to avoid SVG rendering issues in test env
vi.mock("lucide-react", () => ({
Play: () => <span data-testid="play-icon">▶</span>,
Pause: () => <span data-testid="status-pause-icon">⏸</span>,
Square: () => <span data-testid="stop-icon">■</span>,
AlertCircle: () => <span data-testid="alert-icon">⚠</span>,
Loader2: () => <span data-testid="loader-icon">⟳</span>,
MoreHorizontal: () => null,
Trash2: () => <span data-testid="trash-icon">🗑</span>,
Folder: () => <span data-testid="folder-icon">📁</span>,
ArrowRight: () => <span data-testid="arrow-icon">→</span>,
}));
function makeProject(overrides: Partial<RegisteredProject> = {}): RegisteredProject {
return {
id: "proj_abc123",
name: "Test Project",
path: "/home/user/projects/test",
status: "active",
isolationMode: "in-process",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
function makeHealth(overrides: Partial<ProjectHealth> = {}): ProjectHealth {
return {
projectId: "proj_abc123",
status: "active",
activeTaskCount: 5,
inFlightAgentCount: 2,
totalTasksCompleted: 100,
totalTasksFailed: 3,
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
const noop = () => {};
afterEach(() => {
vi.useRealTimers();
});
describe("ProjectCard", () => {
it("renders project name and path", () => {
render(
<ProjectCard
project={makeProject({ name: "My Project", path: "/path/to/project" })}
health={makeHealth()}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
expect(screen.getByText("My Project")).toBeDefined();
expect(screen.getByText("/path/to/project")).toBeDefined();
});
it("renders node availability rows with path details", () => {
render(
<ProjectCard
project={makeProject()}
health={makeHealth()}
availabilityMappings={[{ nodeId: "node-1", displayName: "Remote Worker", path: "/srv/work", available: true }]}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
expect(screen.getByText("Remote Worker")).toBeDefined();
expect(screen.getByText("/srv/work")).toBeDefined();
});
it("shows overflow indicator when more than three mappings exist", () => {
render(
<ProjectCard
project={makeProject()}
health={makeHealth()}
availabilityMappings={[
{ nodeId: "node-1", displayName: "Node One", path: "/one", available: true },
{ nodeId: "node-2", displayName: "Node Two", path: "/two", available: true },
{ nodeId: "node-3", displayName: "Node Three", path: "/three", available: true },
{ nodeId: "node-4", displayName: "Node Four", path: "/four", available: true },
]}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
expect(screen.getByText("+1 more")).toBeDefined();
});
it("truncates long paths", () => {
const longPath = "/very/long/path/to/the/project/directory/that/needs/truncation";
render(
<ProjectCard
project={makeProject({ path: longPath })}
health={makeHealth()}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
// Should show truncated version
const pathElement = screen.getByText(/\/very\/long\/.*\/truncation/);
expect(pathElement).toBeDefined();
});
it("renders active status badge", () => {
render(
<ProjectCard
project={makeProject({ status: "active" })}
health={makeHealth()}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
expect(screen.getByText("Active")).toBeDefined();
});
it("renders paused status badge", () => {
render(
<ProjectCard
project={makeProject({ status: "paused" })}
health={makeHealth({ status: "paused" })}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
expect(screen.getByText("Paused")).toBeDefined();
});
it("renders errored status badge", () => {
render(
<ProjectCard
project={makeProject({ status: "errored" })}
health={makeHealth({ status: "errored" })}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
expect(screen.getByText("Error")).toBeDefined();
});
it("renders initializing status badge with spinner", () => {
render(
<ProjectCard
project={makeProject({ status: "initializing" })}
health={makeHealth({ status: "initializing" })}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
expect(screen.getByText("Initializing")).toBeDefined();
expect(screen.getByTestId("loader-icon")).toBeDefined();
});
it("renders a fallback badge for unknown or missing status values", () => {
expect(() => {
render(
<>
<ProjectCard
project={makeProject({ id: "proj_unknown", status: "removing" as ProjectStatus })}
health={makeHealth({ projectId: "proj_unknown", status: "removing" as ProjectStatus })}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
<ProjectCard
project={makeProject({ id: "proj_missing", status: undefined as unknown as ProjectStatus })}
health={makeHealth({ projectId: "proj_missing", status: undefined as unknown as ProjectStatus })}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
</>
);
}).not.toThrow();
expect(screen.getByText("Removing")).toBeDefined();
expect(screen.getByText("Unknown")).toBeDefined();
expect(screen.getAllByTestId("alert-icon")).toHaveLength(2);
});
it("displays health metrics", () => {
render(
<ProjectCard
project={makeProject()}
health={makeHealth({
activeTaskCount: 10,
inFlightAgentCount: 3,
totalTasksCompleted: 250,
})}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
expect(screen.getByText("10")).toBeDefined();
expect(screen.getByText("Active Tasks")).toBeDefined();
expect(screen.getByText("3")).toBeDefined();
expect(screen.getByText("Agents")).toBeDefined();
expect(screen.getByText("250")).toBeDefined();
expect(screen.getByText("Completed")).toBeDefined();
});
it("shows 'No health data' when health is null", () => {
render(
<ProjectCard
project={makeProject()}
health={null}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
expect(screen.getByText("No health data available")).toBeDefined();
});
it("formats relative time for last activity", () => {
const recentTime = new Date(Date.now() - 5 * 60000).toISOString(); // 5 minutes ago
render(
<ProjectCard
project={makeProject({ lastActivityAt: recentTime })}
health={makeHealth()}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
expect(screen.getByText("5m ago")).toBeDefined();
});
it("shows 'Never' when no last activity", () => {
render(
<ProjectCard
project={makeProject({ lastActivityAt: undefined })}
health={makeHealth({ lastActivityAt: undefined })}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
expect(screen.getByText("Never")).toBeDefined();
});
it("preserves byte-identical relative time output buckets for last activity", () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-06-17T20:00:00.000Z"));
const cases = [
["under-minute", "2026-06-17T19:59:30.000Z", "Just now"],
["minute", "2026-06-17T19:55:00.000Z", "5m ago"],
["hour", "2026-06-17T17:00:00.000Z", "3h ago"],
["day", "2026-06-14T20:00:00.000Z", "3d ago"],
["future", "2026-06-17T20:00:01.000Z", "Just now"],
["invalid", "not-a-date", "Invalid Date"],
["older", "2026-06-10T20:00:00.000Z", new Date("2026-06-10T20:00:00.000Z").toLocaleDateString()],
] as const;
render(
<>
{cases.map(([id, timestamp]) => (
<ProjectCard
key={id}
project={makeProject({ id, lastActivityAt: timestamp })}
health={makeHealth({ projectId: id, lastActivityAt: timestamp })}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
))}
<ProjectCard
project={makeProject({ id: "never", lastActivityAt: undefined })}
health={makeHealth({ projectId: "never", lastActivityAt: undefined })}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
</>,
);
for (const [, , expected] of cases) {
expect(screen.getAllByText(expected).length).toBeGreaterThan(0);
}
expect(screen.getByText("Never")).toBeDefined();
});
it("calls onSelect when card is clicked", () => {
const onSelect = vi.fn();
const project = makeProject();
const { container } = render(
<ProjectCard
project={project}
health={makeHealth()}
onSelect={onSelect}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
const card = container.querySelector('[data-project-id="proj_abc123"]');
expect(card).not.toBeNull();
fireEvent.click(card!);
expect(onSelect).toHaveBeenCalledWith(project);
});
it("calls onSelect when Enter key is pressed", () => {
const onSelect = vi.fn();
const project = makeProject();
const { container } = render(
<ProjectCard
project={project}
health={makeHealth()}
onSelect={onSelect}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
const card = container.querySelector('[data-project-id="proj_abc123"]');
expect(card).not.toBeNull();
fireEvent.keyDown(card!, { key: "Enter" });
expect(onSelect).toHaveBeenCalledWith(project);
});
it("calls onPause when stop-engine button is clicked", () => {
const onPause = vi.fn();
const project = makeProject({ status: "active" });
render(
<ProjectCard
project={project}
health={makeHealth()}
onSelect={noop}
onPause={onPause}
onResume={noop}
onRemove={noop}
/>
);
fireEvent.click(screen.getByLabelText("Stop engine"));
expect(onPause).toHaveBeenCalledWith(project);
});
it("calls onResume when start-engine button is clicked", () => {
const onResume = vi.fn();
const project = makeProject({ status: "paused" });
render(
<ProjectCard
project={project}
health={makeHealth({ status: "paused" })}
onSelect={noop}
onPause={noop}
onResume={onResume}
onRemove={noop}
/>
);
fireEvent.click(screen.getByLabelText("Start engine"));
expect(onResume).toHaveBeenCalledWith(project);
});
it("labels engine lifecycle controls across active, errored, paused, and initializing states", () => {
const activePause = vi.fn();
const activeProject = makeProject({ status: "active" });
const activeRender = render(
<ProjectCard
project={activeProject}
health={makeHealth({ status: "active" })}
onSelect={noop}
onPause={activePause}
onResume={noop}
onRemove={noop}
/>
);
const activeStop = screen.getByRole("button", { name: "Stop engine" });
expect(activeStop).toHaveAttribute("title", "Stop engine");
expect(screen.getByTestId("stop-icon")).toBeDefined();
const stalePauseLabel = ["Pause", "project"].join(" ");
const staleResumeLabel = ["Resume", "project"].join(" ");
expect(screen.queryByLabelText(stalePauseLabel)).toBeNull();
expect(screen.queryByLabelText(staleResumeLabel)).toBeNull();
fireEvent.click(activeStop);
expect(activePause).toHaveBeenCalledWith(activeProject);
activeRender.unmount();
const erroredPause = vi.fn();
const erroredProject = makeProject({ status: "errored" });
const erroredRender = render(
<ProjectCard
project={erroredProject}
health={makeHealth({ status: "errored" })}
onSelect={noop}
onPause={erroredPause}
onResume={noop}
onRemove={noop}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Stop engine" }));
expect(erroredPause).toHaveBeenCalledWith(erroredProject);
erroredRender.unmount();
const pausedResume = vi.fn();
const pausedProject = makeProject({ status: "paused" });
const pausedRender = render(
<ProjectCard
project={pausedProject}
health={makeHealth({ status: "paused" })}
onSelect={noop}
onPause={noop}
onResume={pausedResume}
onRemove={noop}
/>
);
const pausedStart = screen.getByRole("button", { name: "Start engine" });
expect(pausedStart).toHaveAttribute("title", "Start engine");
expect(screen.getByTestId("play-icon")).toBeDefined();
fireEvent.click(pausedStart);
expect(pausedResume).toHaveBeenCalledWith(pausedProject);
pausedRender.unmount();
render(
<ProjectCard
project={makeProject({ status: "initializing" })}
health={makeHealth({ status: "initializing" })}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
const initializingStop = screen.getByRole("button", { name: "Stop engine" });
expect(initializingStop).toBeDisabled();
expect(initializingStop).toHaveAttribute("title", "Cannot stop engine while initializing");
});
it("calls onRemove when remove button is clicked", () => {
const onRemove = vi.fn();
const project = makeProject();
render(
<ProjectCard
project={project}
health={makeHealth()}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={onRemove}
/>
);
// First click arms the button (does not call onRemove)
fireEvent.click(screen.getByLabelText("Remove project"));
expect(onRemove).not.toHaveBeenCalled();
// Second click confirms and calls onRemove
fireEvent.click(screen.getByLabelText("Confirm remove project"));
expect(onRemove).toHaveBeenCalledWith(project);
});
it("shows armed state on first click of remove button", () => {
const onRemove = vi.fn();
const { container } = render(
<ProjectCard
project={makeProject()}
health={makeHealth()}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={onRemove}
/>
);
// Initially not armed
expect(screen.getByLabelText("Remove project")).toBeDefined();
expect(container.querySelector(".is-armed")).toBeNull();
// First click arms the button
fireEvent.click(screen.getByLabelText("Remove project"));
expect(onRemove).not.toHaveBeenCalled();
// Button now shows confirm state
expect(screen.getByLabelText("Confirm remove project")).toBeDefined();
expect(container.querySelector(".is-armed")).not.toBeNull();
});
it("disables stop-engine button when initializing", () => {
const { container } = render(
<ProjectCard
project={makeProject({ status: "initializing" })}
health={makeHealth({ status: "initializing" })}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
// Find the stop-engine button by its title attribute
const stopEngineButton = container.querySelector('button[title="Cannot stop engine while initializing"]');
expect(stopEngineButton).not.toBeNull();
expect(stopEngineButton).toBeDisabled();
});
it("disables all buttons when isLoading is true", () => {
render(
<ProjectCard
project={makeProject({ status: "active" })}
health={makeHealth()}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
isLoading={true}
/>
);
expect(screen.getByLabelText("Stop engine")).toBeDisabled();
expect(screen.getByLabelText("Open project")).toBeDisabled();
expect(screen.getByLabelText("Remove project")).toBeDisabled();
});
it("adds loading class when isLoading is true", () => {
const { container } = render(
<ProjectCard
project={makeProject()}
health={makeHealth()}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
isLoading={true}
/>
);
expect(container.querySelector(".project-card-loading")).toBeDefined();
});
it("adds errored class when project status is errored", () => {
const { container } = render(
<ProjectCard
project={makeProject({ status: "errored" })}
health={makeHealth({ status: "errored" })}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
expect(container.querySelector(".project-card-errored")).toBeDefined();
});
it("prevents event bubbling when clicking action buttons", () => {
const onSelect = vi.fn();
const onPause = vi.fn();
render(
<ProjectCard
project={makeProject({ status: "active" })}
health={makeHealth()}
onSelect={onSelect}
onPause={onPause}
onResume={noop}
onRemove={noop}
/>
);
fireEvent.click(screen.getByLabelText("Stop engine"));
expect(onPause).toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
});
it("renders with data-project-id attribute", () => {
const { container } = render(
<ProjectCard
project={makeProject({ id: "proj_test123" })}
health={makeHealth()}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
const card = container.querySelector("[data-project-id='proj_test123']");
expect(card).toBeDefined();
});
it("uses health lastActivityAt as fallback when project lastActivityAt is undefined", () => {
const healthTime = "2026-01-15T12:00:00.000Z";
render(
<ProjectCard
project={makeProject({ lastActivityAt: undefined })}
health={makeHealth({ lastActivityAt: healthTime })}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
// Should show the health's lastActivityAt formatted (shows as date since it's > 7 days ago)
expect(screen.getByText(/Last activity:/)).toBeDefined();
// The formatted date should show (1/15/2026 format in US locale)
expect(screen.getByText(/1\//)).toBeDefined();
});
describe("mobile responsive structure", () => {
it("renders card with correct class structure for mobile CSS targets", () => {
const { container } = render(
<ProjectCard
project={makeProject()}
health={makeHealth()}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
// Verify all mobile-targeted class names exist
expect(container.querySelector(".project-card")).not.toBeNull();
expect(container.querySelector(".project-card-header")).not.toBeNull();
expect(container.querySelector(".project-card-health")).not.toBeNull();
expect(container.querySelector(".project-card-footer")).not.toBeNull();
expect(container.querySelector(".project-card-actions")).not.toBeNull();
// Verify action buttons have mobile-targeted classes
expect(container.querySelector(".project-card-action")).not.toBeNull();
});
});
});