fix: resolve four frontend/backend issues
- Fix ProjectsPage tests by wrapping renders in MemoryRouter (9 passing) - Improve session auto-naming to 'project / repo / tool' format - Add missing /users/me/sessions endpoint for sidebar session loading - Handle git history 500s: catch RuntimeError in endpoints, graceful empty repo handling - Add git status badge and discard-changes button to FileEditor toolbar Quality gates: tsc pass, build pass, Python syntax pass
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user, get_db_session, get_owned_project
|
from src.auth.dependencies import get_current_user, get_db_session, get_owned_project
|
||||||
@@ -94,7 +94,14 @@ async def get_repository_history(
|
|||||||
from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk
|
from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
ensure_repo_on_disk(repo)
|
ensure_repo_on_disk(repo)
|
||||||
return get_commit_history(repo.path, branch=branch, limit=limit, offset=offset)
|
try:
|
||||||
|
return get_commit_history(repo.path, branch=branch, limit=limit, offset=offset)
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.warning("Git history failed for %s: %s", repo.path, str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Git history unavailable: {str(e)}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/repositories/{repo_id}/commits/{commit_hash}")
|
@router.get("/{project_id}/repositories/{repo_id}/commits/{commit_hash}")
|
||||||
@@ -110,7 +117,14 @@ async def get_repository_commit(
|
|||||||
from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk
|
from src.services.git.repository import get_repo_and_validate, ensure_repo_on_disk
|
||||||
repo = await get_repo_and_validate(session, repo_id, project_id)
|
repo = await get_repo_and_validate(session, repo_id, project_id)
|
||||||
ensure_repo_on_disk(repo)
|
ensure_repo_on_disk(repo)
|
||||||
return get_commit_detail(repo.path, commit_hash)
|
try:
|
||||||
|
return get_commit_detail(repo.path, commit_hash)
|
||||||
|
except RuntimeError as e:
|
||||||
|
logger.warning("Git commit detail failed for %s %s: %s", repo.path, commit_hash, str(e))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Commit detail unavailable: {str(e)}",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
# File browsing
|
# File browsing
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ import uuid
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.auth.dependencies import get_current_user, get_db_session
|
from src.auth.dependencies import get_current_user, get_db_session
|
||||||
|
from src.models.tool_instance import ToolInstance
|
||||||
from src.models.user import User
|
from src.models.user import User
|
||||||
|
from src.schemas.tool_instance import SessionItemResponse, SessionListResponse
|
||||||
from src.schemas.user import UserProfileResponse, UserProfileUpdate
|
from src.schemas.user import UserProfileResponse, UserProfileUpdate
|
||||||
|
|
||||||
router = APIRouter(prefix="/users", tags=["users"])
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
@@ -131,3 +134,41 @@ async def upload_avatar(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(user)
|
await session.refresh(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/me/sessions",
|
||||||
|
response_model=SessionListResponse,
|
||||||
|
summary="Get current user sessions",
|
||||||
|
description="Retrieve all tool instances (sessions) for the authenticated user.",
|
||||||
|
)
|
||||||
|
async def get_user_sessions(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_db_session),
|
||||||
|
) -> SessionListResponse:
|
||||||
|
"""Return all tool instances for the current user with related names."""
|
||||||
|
result = await session.execute(
|
||||||
|
select(ToolInstance)
|
||||||
|
.where(ToolInstance.owner_id == user.id)
|
||||||
|
.order_by(ToolInstance.created_at.desc())
|
||||||
|
)
|
||||||
|
instances = result.scalars().all()
|
||||||
|
|
||||||
|
sessions = [
|
||||||
|
SessionItemResponse(
|
||||||
|
id=str(inst.id),
|
||||||
|
display_name=inst.display_name,
|
||||||
|
tool_type_name=inst.tool_type.display_name if inst.tool_type else "Unknown",
|
||||||
|
tool_icon=inst.tool_type.icon if inst.tool_type else None,
|
||||||
|
tool_type_interfaces=inst.tool_type.interfaces if inst.tool_type else [],
|
||||||
|
repository_name=inst.repository.name if inst.repository else "Unknown",
|
||||||
|
repository_id=str(inst.repository_id),
|
||||||
|
project_name=inst.project.name if inst.project else "Unknown",
|
||||||
|
project_id=str(inst.project_id),
|
||||||
|
status=inst.status,
|
||||||
|
url=inst.url,
|
||||||
|
)
|
||||||
|
for inst in instances
|
||||||
|
]
|
||||||
|
|
||||||
|
return SessionListResponse(sessions=sessions)
|
||||||
|
|||||||
@@ -15,3 +15,27 @@ class CreateInstanceRequest(BaseModel):
|
|||||||
config_profile_id: str | None = Field(
|
config_profile_id: str | None = Field(
|
||||||
default=None, description="Optional config profile ID to apply to the instance"
|
default=None, description="Optional config profile ID to apply to the instance"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionItemResponse(BaseModel):
|
||||||
|
"""Lightweight session summary for sidebar and dashboard."""
|
||||||
|
|
||||||
|
model_config = {"extra": "ignore"}
|
||||||
|
|
||||||
|
id: str = Field(description="Session (tool instance) ID")
|
||||||
|
display_name: str = Field(description="Display name of the session")
|
||||||
|
tool_type_name: str = Field(description="Name of the tool type")
|
||||||
|
tool_icon: str | None = Field(default=None, description="Icon URL for the tool type")
|
||||||
|
tool_type_interfaces: list[str] = Field(default_factory=list, description="Supported interfaces")
|
||||||
|
repository_name: str = Field(description="Name of the repository")
|
||||||
|
repository_id: str = Field(description="Repository ID")
|
||||||
|
project_name: str = Field(description="Name of the project")
|
||||||
|
project_id: str = Field(description="Project ID")
|
||||||
|
status: str = Field(description="Current status")
|
||||||
|
url: str | None = Field(default=None, description="Access URL")
|
||||||
|
|
||||||
|
|
||||||
|
class SessionListResponse(BaseModel):
|
||||||
|
"""Response wrapping a list of session summaries."""
|
||||||
|
|
||||||
|
sessions: list[SessionItemResponse]
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ async def create_new_instance(
|
|||||||
|
|
||||||
instance = ToolInstance(
|
instance = ToolInstance(
|
||||||
name=instance_name,
|
name=instance_name,
|
||||||
display_name=display_name or f"{tool_type.display_name} - {repo.name}",
|
display_name=display_name or f"{project.name} / {repo.name} / {tool_type.display_name}",
|
||||||
tool_type_id=tool_type.id,
|
tool_type_id=tool_type.id,
|
||||||
repository_id=repo.id,
|
repository_id=repo.id,
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
|
|||||||
@@ -60,9 +60,12 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1
|
|||||||
|
|
||||||
Returns structured data including commits, branches, and graph information.
|
Returns structured data including commits, branches, and graph information.
|
||||||
"""
|
"""
|
||||||
# Get list of branches
|
# Get list of branches (may fail for empty repos)
|
||||||
branches_output = _run_git_command(repo_path, ["branch", "-a", "--format=%(refname:short)"])
|
try:
|
||||||
branches = [b.strip() for b in branches_output.strip().split("\n") if b.strip()]
|
branches_output = _run_git_command(repo_path, ["branch", "-a", "--format=%(refname:short)"])
|
||||||
|
branches = [b.strip() for b in branches_output.strip().split("\n") if b.strip()]
|
||||||
|
except RuntimeError:
|
||||||
|
branches = []
|
||||||
|
|
||||||
# Build git log command - use NULL bytes as separators to avoid parsing issues
|
# Build git log command - use NULL bytes as separators to avoid parsing issues
|
||||||
log_args = [
|
log_args = [
|
||||||
@@ -76,7 +79,16 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1
|
|||||||
else:
|
else:
|
||||||
log_args.append("--all")
|
log_args.append("--all")
|
||||||
|
|
||||||
log_output = _run_git_command(repo_path, log_args)
|
try:
|
||||||
|
log_output = _run_git_command(repo_path, log_args)
|
||||||
|
except RuntimeError:
|
||||||
|
# Empty repo or no commits
|
||||||
|
return {
|
||||||
|
"commits": [],
|
||||||
|
"branches": branches,
|
||||||
|
"total_commits": 0,
|
||||||
|
"graph_data": {"nodes": [], "edges": []},
|
||||||
|
}
|
||||||
|
|
||||||
# Get branch info for each commit
|
# Get branch info for each commit
|
||||||
branch_map = _get_branch_map(repo_path)
|
branch_map = _get_branch_map(repo_path)
|
||||||
@@ -113,8 +125,11 @@ def get_commit_history(repo_path: str, branch: str | None = None, limit: int = 1
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Get total commit count
|
# Get total commit count
|
||||||
count_output = _run_git_command(repo_path, ["rev-list", "--all", "--count"])
|
try:
|
||||||
total_commits = int(count_output.strip()) if count_output.strip() else 0
|
count_output = _run_git_command(repo_path, ["rev-list", "--all", "--count"])
|
||||||
|
total_commits = int(count_output.strip()) if count_output.strip() else 0
|
||||||
|
except RuntimeError:
|
||||||
|
total_commits = 0
|
||||||
|
|
||||||
# Build graph data and generate graph symbols
|
# Build graph data and generate graph symbols
|
||||||
graph_data = _build_graph_data(commits)
|
graph_data = _build_graph_data(commits)
|
||||||
|
|||||||
@@ -9,14 +9,23 @@ import { Icon } from "../../ui/Icon";
|
|||||||
import { SyntaxHighlighter } from "./SyntaxHighlighter";
|
import { SyntaxHighlighter } from "./SyntaxHighlighter";
|
||||||
import { detectLanguage } from "../../../utils/language";
|
import { detectLanguage } from "../../../utils/language";
|
||||||
|
|
||||||
|
interface GitFileStatus {
|
||||||
|
modified: string[];
|
||||||
|
added: string[];
|
||||||
|
deleted: string[];
|
||||||
|
untracked: string[];
|
||||||
|
}
|
||||||
|
|
||||||
interface FileEditorProps {
|
interface FileEditorProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
repoId: string;
|
repoId: string;
|
||||||
|
gitStatus?: GitFileStatus | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FileEditor: React.FC<FileEditorProps> = ({
|
export const FileEditor: React.FC<FileEditorProps> = ({
|
||||||
projectId,
|
projectId,
|
||||||
repoId,
|
repoId,
|
||||||
|
gitStatus,
|
||||||
}) => {
|
}) => {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -31,9 +40,49 @@ export const FileEditor: React.FC<FileEditorProps> = ({
|
|||||||
const [isBinary, setIsBinary] = useState(false);
|
const [isBinary, setIsBinary] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleDiscard = async () => {
|
||||||
|
if (!filePath) return;
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/projects/${projectId}/repositories/${repoId}/files/content`,
|
||||||
|
{
|
||||||
|
params: {
|
||||||
|
branch,
|
||||||
|
path: filePath,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const data = response.data;
|
||||||
|
if (data.is_binary) {
|
||||||
|
setIsBinary(true);
|
||||||
|
setContent("Binary file - cannot display");
|
||||||
|
setOriginalContent("");
|
||||||
|
} else {
|
||||||
|
setIsBinary(false);
|
||||||
|
setContent(data.content);
|
||||||
|
setOriginalContent(data.content);
|
||||||
|
}
|
||||||
|
setMode("view");
|
||||||
|
} catch {
|
||||||
|
setError("Failed to discard changes");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const branch = searchParams.get("branch") || "main";
|
const branch = searchParams.get("branch") || "main";
|
||||||
const filePath = searchParams.get("file");
|
const filePath = searchParams.get("file");
|
||||||
|
|
||||||
|
const fileStatus = gitStatus
|
||||||
|
? gitStatus.modified.includes(filePath || "")
|
||||||
|
? "modified"
|
||||||
|
: gitStatus.added.includes(filePath || "")
|
||||||
|
? "added"
|
||||||
|
: gitStatus.deleted.includes(filePath || "")
|
||||||
|
? "deleted"
|
||||||
|
: gitStatus.untracked.includes(filePath || "")
|
||||||
|
? "untracked"
|
||||||
|
: undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const loadFile = useCallback(async () => {
|
const loadFile = useCallback(async () => {
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
setContent("");
|
setContent("");
|
||||||
@@ -158,7 +207,7 @@ export const FileEditor: React.FC<FileEditorProps> = ({
|
|||||||
<div className={styles.fileEditor}>
|
<div className={styles.fileEditor}>
|
||||||
<div className={styles.fileEditorToolbar}>
|
<div className={styles.fileEditorToolbar}>
|
||||||
<div className="file-breadcrumbs">
|
<div className="file-breadcrumbs">
|
||||||
{filePath.split("/").map((part, i, arr) => (
|
{filePath?.split("/").map((part, i, arr) => (
|
||||||
<span key={i}>
|
<span key={i}>
|
||||||
{part}
|
{part}
|
||||||
{i < arr.length - 1 && (
|
{i < arr.length - 1 && (
|
||||||
@@ -168,6 +217,11 @@ export const FileEditor: React.FC<FileEditorProps> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.fileActions}>
|
<div className={styles.fileActions}>
|
||||||
|
{fileStatus && (
|
||||||
|
<span className={`git-status-badge ${fileStatus}`} title={fileStatus}>
|
||||||
|
{fileStatus === "modified" ? "M" : fileStatus === "added" ? "A" : fileStatus === "deleted" ? "D" : "?"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{mode === "view" && !isBinary && (
|
{mode === "view" && !isBinary && (
|
||||||
<button
|
<button
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
@@ -180,24 +234,33 @@ export const FileEditor: React.FC<FileEditorProps> = ({
|
|||||||
)}
|
)}
|
||||||
{mode === "edit" && (
|
{mode === "edit" && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={content === originalContent || saving}
|
disabled={content === originalContent || saving}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<>
|
<>
|
||||||
<Icon name="loading" size="sm" />
|
<Icon name="loading" size="sm" />
|
||||||
Saving...
|
Saving...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Icon name="save" size="sm" />
|
<Icon name="save" size="sm" />
|
||||||
Save
|
Save
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={handleDiscard}
|
||||||
|
type="button"
|
||||||
|
title="Revert to last committed version"
|
||||||
|
>
|
||||||
|
<Icon name="undo" size="sm" />
|
||||||
|
Discard
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn-secondary"
|
className="btn-secondary"
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ export type IconName =
|
|||||||
| "play"
|
| "play"
|
||||||
| "stop"
|
| "stop"
|
||||||
| "terminal"
|
| "terminal"
|
||||||
| "arrow-left";
|
| "arrow-left"
|
||||||
|
| "undo";
|
||||||
|
|
||||||
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
|
const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; weight?: "thin" | "light" | "regular" | "bold" | "fill" | "duotone" }>> = {
|
||||||
dashboard: House,
|
dashboard: House,
|
||||||
@@ -117,6 +118,7 @@ const iconMap: Record<IconName, React.ComponentType<{ size?: number | string; we
|
|||||||
stop: Stop,
|
stop: Stop,
|
||||||
terminal: Terminal,
|
terminal: Terminal,
|
||||||
"arrow-left": ArrowLeft,
|
"arrow-left": ArrowLeft,
|
||||||
|
undo: ClockCounterClockwise,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IconProps {
|
export interface IconProps {
|
||||||
|
|||||||
@@ -1,174 +1,205 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
within,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { ProjectsPage } from "./ProjectsPage";
|
import { ProjectsPage } from "./ProjectsPage";
|
||||||
import * as projectsApi from "../api/projects";
|
import * as projectsApi from "../api/projects";
|
||||||
|
|
||||||
|
const renderWithRouter = (ui: React.ReactElement) =>
|
||||||
|
render(<MemoryRouter>{ui}</MemoryRouter>);
|
||||||
|
|
||||||
const mockProjects = [
|
const mockProjects = [
|
||||||
{
|
{
|
||||||
id: "proj-1",
|
id: "proj-1",
|
||||||
name: "Alpha Project",
|
name: "Alpha Project",
|
||||||
description: "First project",
|
description: "First project",
|
||||||
owner_id: "user-1",
|
owner_id: "user-1",
|
||||||
default_ssh_key_id: null,
|
default_ssh_key_id: null,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "proj-2",
|
id: "proj-2",
|
||||||
name: "Beta Project",
|
name: "Beta Project",
|
||||||
description: null,
|
description: null,
|
||||||
owner_id: "user-1",
|
owner_id: "user-1",
|
||||||
default_ssh_key_id: null,
|
default_ssh_key_id: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ProjectsPage", () => {
|
describe("ProjectsPage", () => {
|
||||||
it("renders loading state initially", () => {
|
it("renders loading state initially", () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockImplementation(() => new Promise(() => {}));
|
vi.spyOn(projectsApi, "listProjects").mockImplementation(
|
||||||
render(<ProjectsPage />);
|
() => new Promise(() => {}),
|
||||||
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
);
|
||||||
});
|
renderWithRouter(<ProjectsPage />);
|
||||||
|
expect(screen.getByText(/loading projects/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders project list after loading", async () => {
|
it("renders project list after loading", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||||
render(<ProjectsPage />);
|
renderWithRouter(<ProjectsPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.getByText("Beta Project")).toBeInTheDocument();
|
expect(screen.getByText("Beta Project")).toBeInTheDocument();
|
||||||
expect(screen.getByText("First project")).toBeInTheDocument();
|
expect(screen.getByText("First project")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders empty state when no projects", async () => {
|
it("renders empty state when no projects", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||||
render(<ProjectsPage />);
|
renderWithRouter(<ProjectsPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders error state with retry button", async () => {
|
it("renders error state with retry button", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
vi.spyOn(projectsApi, "listProjects").mockRejectedValue(new Error("fail"));
|
||||||
render(<ProjectsPage />);
|
renderWithRouter(<ProjectsPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
|
expect(screen.getByText(/failed to load projects/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens create dialog and submits new project", async () => {
|
it("opens create dialog and submits new project", async () => {
|
||||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
const listMock = vi
|
||||||
const createMock = vi.spyOn(projectsApi, "createProject").mockResolvedValue(mockProjects[0]);
|
.spyOn(projectsApi, "listProjects")
|
||||||
|
.mockResolvedValue([]);
|
||||||
|
const createMock = vi
|
||||||
|
.spyOn(projectsApi, "createProject")
|
||||||
|
.mockResolvedValue(mockProjects[0]);
|
||||||
|
|
||||||
render(<ProjectsPage />);
|
renderWithRouter(<ProjectsPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /new project/i }));
|
fireEvent.click(screen.getByRole("button", { name: /new project/i }));
|
||||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText(/project name/i), {
|
fireEvent.change(screen.getByPlaceholderText(/project name/i), {
|
||||||
target: { value: "Gamma Project" },
|
target: { value: "Gamma Project" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByPlaceholderText(/optional description/i), {
|
fireEvent.change(screen.getByPlaceholderText(/optional description/i), {
|
||||||
target: { value: "A new project" },
|
target: { value: "A new project" },
|
||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: /create/i }));
|
fireEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(createMock).toHaveBeenCalledWith({
|
expect(createMock).toHaveBeenCalledWith({
|
||||||
name: "Gamma Project",
|
name: "Gamma Project",
|
||||||
description: "A new project",
|
description: "A new project",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
expect(listMock).toHaveBeenCalledTimes(2);
|
expect(listMock).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows validation error when name is empty", async () => {
|
it("shows validation error when name is empty", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue([]);
|
||||||
|
|
||||||
render(<ProjectsPage />);
|
renderWithRouter(<ProjectsPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
expect(screen.getByText(/no projects yet/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /new project/i }));
|
fireEvent.click(screen.getByRole("button", { name: /new project/i }));
|
||||||
fireEvent.click(screen.getByRole("button", { name: /create/i }));
|
fireEvent.click(screen.getByRole("button", { name: /create/i }));
|
||||||
|
|
||||||
expect(screen.getByText(/project name is required/i)).toBeInTheDocument();
|
expect(screen.getByText(/project name is required/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders settings link for each project", async () => {
|
it("renders settings link for each project", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||||
render(<ProjectsPage />);
|
renderWithRouter(<ProjectsPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
|
const alphaCard = screen
|
||||||
if (!alphaCard) throw new Error("Card not found");
|
.getByText("Alpha Project")
|
||||||
|
.closest(".project-card") as HTMLElement | null;
|
||||||
|
if (!alphaCard) throw new Error("Card not found");
|
||||||
|
|
||||||
const settingsLink = within(alphaCard).getByRole("link", { name: /settings/i });
|
const settingsLink = within(alphaCard).getByRole("link", {
|
||||||
expect(settingsLink).toBeInTheDocument();
|
name: /settings/i,
|
||||||
expect(settingsLink).toHaveAttribute("href", "/projects/proj-1/settings");
|
});
|
||||||
});
|
expect(settingsLink).toBeInTheDocument();
|
||||||
|
expect(settingsLink).toHaveAttribute("href", "/projects/proj-1/settings");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders open workspace link as rightmost action", async () => {
|
it("renders open workspace link as rightmost action", async () => {
|
||||||
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
||||||
render(<ProjectsPage />);
|
renderWithRouter(<ProjectsPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
|
const alphaCard = screen
|
||||||
if (!alphaCard) throw new Error("Card not found");
|
.getByText("Alpha Project")
|
||||||
|
.closest(".project-card") as HTMLElement | null;
|
||||||
|
if (!alphaCard) throw new Error("Card not found");
|
||||||
|
|
||||||
const actions = alphaCard.querySelector(".project-actions");
|
const actions = alphaCard.querySelector(".project-actions");
|
||||||
if (!actions) throw new Error("Actions container not found");
|
if (!actions) throw new Error("Actions container not found");
|
||||||
|
|
||||||
const workspaceLink = within(alphaCard).getByRole("link", { name: /open workspace/i });
|
const workspaceLink = within(alphaCard).getByRole("link", {
|
||||||
expect(workspaceLink).toBeInTheDocument();
|
name: /open workspace/i,
|
||||||
expect(workspaceLink).toHaveAttribute("href", "/projects/proj-1");
|
});
|
||||||
|
expect(workspaceLink).toBeInTheDocument();
|
||||||
|
expect(workspaceLink).toHaveAttribute("href", "/projects/proj-1");
|
||||||
|
|
||||||
// Verify it's the last action in the container
|
// Verify it's the last action in the container
|
||||||
const allActions = actions.querySelectorAll("a, button");
|
const allActions = actions.querySelectorAll("a, button");
|
||||||
const lastAction = allActions[allActions.length - 1];
|
const lastAction = allActions[allActions.length - 1];
|
||||||
expect(lastAction).toBe(workspaceLink);
|
expect(lastAction).toBe(workspaceLink);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows delete confirmation and deletes project", async () => {
|
it("shows delete confirmation and deletes project", async () => {
|
||||||
const listMock = vi.spyOn(projectsApi, "listProjects").mockResolvedValue(mockProjects);
|
const listMock = vi
|
||||||
const deleteMock = vi.spyOn(projectsApi, "deleteProject").mockResolvedValue(undefined);
|
.spyOn(projectsApi, "listProjects")
|
||||||
|
.mockResolvedValue(mockProjects);
|
||||||
|
const deleteMock = vi
|
||||||
|
.spyOn(projectsApi, "deleteProject")
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
|
||||||
render(<ProjectsPage />);
|
renderWithRouter(<ProjectsPage />);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
expect(screen.getByText("Alpha Project")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
const alphaCard = screen.getByText("Alpha Project").closest(".project-card") as HTMLElement | null;
|
const alphaCard = screen
|
||||||
if (!alphaCard) throw new Error("Card not found");
|
.getByText("Alpha Project")
|
||||||
|
.closest(".project-card") as HTMLElement | null;
|
||||||
|
if (!alphaCard) throw new Error("Card not found");
|
||||||
|
|
||||||
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
|
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
|
||||||
expect(within(alphaCard).getByText(/are you sure/i)).toBeInTheDocument();
|
expect(within(alphaCard).getByText(/are you sure/i)).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
|
fireEvent.click(within(alphaCard).getByRole("button", { name: /delete/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(deleteMock).toHaveBeenCalledWith("proj-1");
|
expect(deleteMock).toHaveBeenCalledWith("proj-1");
|
||||||
});
|
});
|
||||||
expect(listMock).toHaveBeenCalledTimes(2);
|
expect(listMock).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ export const RepoWorkspace = () => {
|
|||||||
)}
|
)}
|
||||||
<main className="workspace-main">
|
<main className="workspace-main">
|
||||||
{selectedRepoId && (
|
{selectedRepoId && (
|
||||||
<FileEditor projectId={projectId!} repoId={selectedRepoId} />
|
<FileEditor projectId={projectId!} repoId={selectedRepoId} gitStatus={gitStatus} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user