Merge pull request #1 from buihongduc132/feat/project-selector-enhancements

feat(dashboard): scrollable, autocomplete, bookmarkable project selector
This commit is contained in:
buihongduc132
2026-06-07 02:39:09 +07:00
committed by GitHub
9 changed files with 2239 additions and 198 deletions

View File

@@ -2,8 +2,9 @@ import { useState, useEffect, useRef, useCallback, useMemo, type KeyboardEvent a
import { useTranslation } from "react-i18next";
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Monitor, Server, Workflow, Bot, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Zap, Sparkles, FileText, Brain, CheckSquare, Lock } from "lucide-react";
import "./Header.css";
// Header renders an inline ProjectSelector dropdown using project-selector-* classes.
// ProjectSelector styles used by the imported standalone component.
import "./ProjectSelector.css";
import { ProjectSelector as StandaloneProjectSelector } from "./ProjectSelector";
import type { ProjectInfo } from "../api";
import type { NodeConfig, ProjectStatus } from "@fusion/core";
import { fetchScripts } from "../api";
@@ -30,125 +31,8 @@ const PROJECT_STATUS_CONFIG: Record<ProjectStatus, { color: string }> = {
initializing: { color: "var(--info)" },
};
/**
* ProjectSelector - A component for project navigation.
* Shows project dropdown for switching projects and navigating to project management.
*/
function ProjectSelector({
projects,
currentProject,
onViewAll,
onSelectProject,
}: {
projects: ProjectInfo[];
currentProject: ProjectInfo | null;
onViewAll: () => void;
onSelectProject?: (project: ProjectInfo) => void;
}) {
const { t } = useTranslation("app");
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click
useEffect(() => {
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
// Close on Escape
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsOpen(false);
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen]);
const handleSelectProject = useCallback(
(project: ProjectInfo) => {
onSelectProject?.(project);
setIsOpen(false);
},
[onSelectProject]
);
return (
<div className="project-selector" ref={dropdownRef}>
{projects.length > 0 && (
<>
<button
className={`project-selector-trigger${isOpen ? " project-selector-trigger--open" : ""}`}
onClick={() => setIsOpen((prev) => !prev)}
title={currentProject?.name ? t("header.switchProjectCurrent", "Switch project (current: {{name}})", { name: currentProject.name }) : t("header.switchProject", "Switch project")}
aria-label={t("header.switchProject", "Switch project")}
aria-expanded={isOpen}
aria-haspopup="listbox"
data-testid="project-selector-trigger"
>
<span className="project-selector-trigger-label">
{currentProject?.name ?? t("header.projects", "Projects")}
</span>
<ChevronDown size={12} className={`project-selector-chevron${isOpen ? " project-selector-chevron--open" : ""}`} />
</button>
{isOpen && (
<div
className="project-selector-dropdown"
role="listbox"
aria-label={t("header.selectProject", "Select project")}
data-testid="project-selector-dropdown"
>
{projects.map((project) => {
const isCurrent = currentProject?.id === project.id;
const statusColor = PROJECT_STATUS_CONFIG[project.status]?.color;
return (
<button
key={project.id}
className={`project-selector-item${isCurrent ? " project-selector-item--current" : ""}`}
onClick={() => handleSelectProject(project)}
role="option"
aria-selected={isCurrent}
>
<span
className="project-selector-dot"
style={{ backgroundColor: statusColor || "var(--text-muted)" }}
/>
<div className="project-selector-info">
<span className="project-selector-name">{project.name}</span>
<span className="project-selector-path">
{getTrailingPath(project.path, 2)}
</span>
</div>
{isCurrent && <Check size={14} className="project-selector-check" />}
</button>
);
})}
<div className="project-selector-divider" role="presentation" />
<button
className="project-selector-manage"
onClick={() => {
onViewAll();
setIsOpen(false);
}}
data-testid="manage-projects-action"
>
{t("header.manageProjects", "Manage Projects")}
</button>
</div>
)}
</>
)}
</div>
);
}
// Inline ProjectSelector removed — now imports StandaloneProjectSelector from ./ProjectSelector
// which has scroll fix, autocomplete, and bookmarking features.
// GitHub logo icon (Octocat mark) - uses currentColor for theme compatibility
function GitHubLogo({ size = 16 }: { size?: number }) {
@@ -953,11 +837,11 @@ export function Header({
{/* Project Selector - Back button when project selected, dropdown when 2+ projects (tablet + desktop) */}
{!isMobile && projects.length >= 1 && onViewAllProjects && (
<ProjectSelector
<StandaloneProjectSelector
projects={projects}
currentProject={currentProject ?? null}
onViewAll={onViewAllProjects}
onSelectProject={onSelectProject}
onSelect={onSelectProject}
/>
)}

View File

@@ -71,11 +71,29 @@
z-index: 100;
min-width: 240px;
max-width: 360px;
max-height: min(480px, calc(100vh - 120px));
overflow-y: auto;
overscroll-behavior: contain;
padding: var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--surface);
box-shadow: var(--shadow-lg);
scrollbar-width: thin;
scrollbar-color: var(--text-dim) transparent;
}
.project-selector__dropdown::-webkit-scrollbar {
width: 6px;
}
.project-selector__dropdown::-webkit-scrollbar-track {
background: transparent;
}
.project-selector__dropdown::-webkit-scrollbar-thumb {
background-color: var(--text-dim);
border-radius: 3px;
}
.project-selector-item,
@@ -199,11 +217,59 @@
}
.project-selector__no-results {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) calc(var(--space-sm) + var(--space-xs));
color: var(--text-muted);
font-size: 13px;
}
.project-selector__no-results-icon {
flex-shrink: 0;
opacity: 0.5;
}
/* Autocomplete highlight — marks the matched text substring */
.project-selector__highlight {
background: transparent;
color: inherit;
font-weight: 700;
text-decoration: underline;
text-decoration-color: var(--todo);
text-underline-offset: 2px;
text-decoration-thickness: 2px;
}
/* Exact match indicator banner */
.project-selector__exact-match {
padding: var(--space-xs) calc(var(--space-sm) + var(--space-xs));
margin-bottom: var(--space-xs);
font-size: 12px;
color: var(--todo);
background: color-mix(in srgb, var(--todo) 8%, transparent);
border-radius: var(--radius-md);
text-align: center;
}
/* Exact match badge shown on item */
.project-selector__exact-badge {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 1px 5px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--todo) 15%, transparent);
color: var(--todo);
flex-shrink: 0;
}
/* Exact match item subtle highlight */
.project-selector__item.exact-match {
background: color-mix(in srgb, var(--todo) 5%, transparent);
}
.project-selector__footer {
margin-top: var(--space-xs);
padding-top: var(--space-xs);
@@ -279,6 +345,44 @@
color: var(--todo);
}
/* Bookmark star toggle */
.project-selector__bookmark {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
user-select: none;
opacity: 0;
transition: opacity var(--transition-fast), color var(--transition-fast), background var(--transition-fast);
}
/* Show star on row hover or when bookmarked */
.project-selector__item:hover .project-selector__bookmark,
.project-selector__item.highlighted .project-selector__bookmark,
.project-selector__bookmark.bookmarked {
opacity: 1;
}
.project-selector__bookmark:hover {
color: var(--todo);
background: var(--card-hover);
}
.project-selector__bookmark.bookmarked {
color: var(--todo);
}
.project-selector__bookmark:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
/* Light theme overrides for project selector */
[data-theme="light"] .project-selector-trigger:hover {
background: var(--card-hover);

View File

@@ -1,5 +1,5 @@
import "./ProjectSelector.css";
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
ChevronDown,
@@ -8,30 +8,68 @@ import {
Grid3X3,
Search,
Clock,
Star,
X,
} from "lucide-react";
import type { ProjectInfo } from "../api";
import type { ProjectStatus } from "@fusion/core";
import { getTrailingPath } from "../utils/pathDisplay";
import { getProjectStatusConfig, isInitializingStatus } from "../utils/projectStatusConfig";
import { useProjectBookmarks } from "../hooks/useProjectBookmarks";
export interface ProjectSelectorProps {
projects: ProjectInfo[];
currentProject: ProjectInfo | null;
onSelect: (project: ProjectInfo) => void;
onSelect?: (project: ProjectInfo) => void;
onViewAll: () => void;
recentProjectIds?: string[];
}
/**
* ProjectSelector - Project switcher dropdown with keyboard navigation
* HighlightMatch — Renders text with matching substring highlighted (bold + accent underline).
* Used to show which part of a project name/path matches the autocomplete query.
*/
function HighlightMatch({
text,
query,
}: {
text: string;
query: string;
}): ReactNode {
if (!query.trim()) return <>{text}</>;
const lowerText = text.toLowerCase();
const lowerQuery = query.toLowerCase();
const matchIndex = lowerText.indexOf(lowerQuery);
if (matchIndex === -1) return <>{text}</>;
const before = text.slice(0, matchIndex);
const match = text.slice(matchIndex, matchIndex + query.length);
const after = text.slice(matchIndex + query.length);
return (
<>
{before}
<mark className="project-selector__highlight">{match}</mark>
{after}
</>
);
}
/**
* ProjectSelector - Project switcher dropdown with autocomplete/type-ahead
*
* Features:
* - Dropdown trigger showing current project name + chevron
* - Always-visible search input with type-ahead filtering
* - Text highlighting showing matched portions of project names/paths
* - Dropdown menu with project list, status icons, "View All Projects" option
* - Keyboard navigation: arrow keys, enter to select, escape to close
* - Search/filter when 5+ projects
* - Recent projects section at top (last 3 accessed)
* - Recent projects section (last 3 accessed)
* - Bookmarked projects section (star toggle, persisted in localStorage)
* - Exact match detection: auto-highlights and Enter-selects the exact match
* - No matches state with clear messaging
*/
export function ProjectSelector({
projects,
@@ -47,6 +85,8 @@ export function ProjectSelector({
const dropdownRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const itemRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
const { bookmarkedIds, toggleBookmark, isBookmarked } = useProjectBookmarks();
// Close dropdown on outside click
useEffect(() => {
@@ -60,6 +100,7 @@ export function ProjectSelector({
!triggerRef.current.contains(e.target as Node)
) {
setIsOpen(false);
setSearchQuery("");
}
};
@@ -74,6 +115,7 @@ export function ProjectSelector({
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsOpen(false);
setSearchQuery("");
triggerRef.current?.focus();
}
};
@@ -82,12 +124,12 @@ export function ProjectSelector({
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen]);
// Focus search input when dropdown opens (if search is visible)
// Focus search input when dropdown opens (always visible for autocomplete)
useEffect(() => {
if (isOpen && projects.length >= 5) {
if (isOpen) {
setTimeout(() => searchInputRef.current?.focus(), 0);
}
}, [isOpen, projects.length]);
}, [isOpen]);
// Get recent projects
const recentProjects = useMemo(() => {
@@ -108,28 +150,57 @@ export function ProjectSelector({
);
}, [projects, searchQuery]);
// Organize projects for display: recent first, then others
// Detect exact match (case-insensitive name match)
const exactMatch = useMemo((): ProjectInfo | null => {
if (!searchQuery.trim()) return null;
const query = searchQuery.toLowerCase();
// Exclude current project — it's not shown in the dropdown
const candidates = filteredProjects.filter(
(p) => p.id !== currentProject?.id
);
const nameMatches = candidates.filter(
(p) => p.name.toLowerCase() === query
);
if (nameMatches.length === 1) return nameMatches[0];
return null;
}, [filteredProjects, searchQuery, currentProject]);
// Organize projects for display: bookmarked first, then recent, then others
const displayProjects = useMemo(() => {
const recentIds = new Set(recentProjects.map((p) => p.id));
const currentId = currentProject?.id;
// Exclude current project from list
// Bookmarked projects (excluding current)
const bookmarked = filteredProjects.filter(
(p) =>
p.id !== currentId &&
bookmarkedIds.has(p.id) &&
!recentIds.has(p.id)
);
// Exclude current, bookmarked, and recent from "others"
const bookmarkedAndRecentIds = new Set([
...bookmarked.map((p) => p.id),
...recentIds,
]);
const others = filteredProjects.filter(
(p) => p.id !== currentId && !recentIds.has(p.id)
(p) => p.id !== currentId && !bookmarkedAndRecentIds.has(p.id)
);
return {
bookmarked: searchQuery.trim() ? [] : bookmarked,
recent: searchQuery.trim() ? [] : recentProjects,
others,
};
}, [filteredProjects, recentProjects, currentProject, searchQuery]);
}, [filteredProjects, recentProjects, currentProject, searchQuery, bookmarkedIds]);
// Calculate total items for keyboard navigation
const totalItems = useMemo(() => {
const bookmarkedCount = displayProjects.bookmarked.length;
const recentCount = displayProjects.recent.length;
const othersCount = displayProjects.others.length;
const viewAllCount = 1;
return recentCount + othersCount + viewAllCount;
return bookmarkedCount + recentCount + othersCount + viewAllCount;
}, [displayProjects]);
// Handle keyboard navigation within dropdown
@@ -151,21 +222,30 @@ export function ProjectSelector({
case "Enter":
e.preventDefault();
if (highlightedIndex >= 0) {
const bookmarkedCount = displayProjects.bookmarked.length;
const recentCount = displayProjects.recent.length;
const othersCount = displayProjects.others.length;
if (highlightedIndex < recentCount) {
if (highlightedIndex < bookmarkedCount) {
// Select bookmarked project
onSelect(displayProjects.bookmarked[highlightedIndex]);
} else if (highlightedIndex < bookmarkedCount + recentCount) {
// Select recent project
onSelect(displayProjects.recent[highlightedIndex]);
} else if (highlightedIndex < recentCount + othersCount) {
onSelect(displayProjects.recent[highlightedIndex - bookmarkedCount]);
} else if (highlightedIndex < bookmarkedCount + recentCount + othersCount) {
// Select other project
onSelect(displayProjects.others[highlightedIndex - recentCount]);
onSelect(displayProjects.others[highlightedIndex - bookmarkedCount - recentCount]);
} else {
// View All
onViewAll();
}
setIsOpen(false);
setSearchQuery("");
} else if (exactMatch) {
// Auto-select exact match on Enter when nothing is highlighted
onSelect?.(exactMatch);
setIsOpen(false);
setSearchQuery("");
}
break;
case "Home":
@@ -178,15 +258,41 @@ export function ProjectSelector({
break;
}
},
[highlightedIndex, totalItems, displayProjects, onSelect, onViewAll]
[highlightedIndex, totalItems, displayProjects, onSelect, onViewAll, exactMatch]
);
// Reset highlight when dropdown opens or search changes
// Auto-highlight first result when filtering (type-ahead behavior)
useEffect(() => {
if (isOpen) {
if (isOpen && searchQuery.trim()) {
if (exactMatch) {
// Auto-highlight the exact match item
const bookmarkedCount = displayProjects.bookmarked.length;
const recentCount = displayProjects.recent.length;
const matchIdx = displayProjects.others.findIndex(
(p) => p.id === exactMatch.id
);
if (matchIdx >= 0) {
setHighlightedIndex(bookmarkedCount + recentCount + matchIdx);
}
} else if (displayProjects.others.length > 0) {
// Highlight first item in others section
setHighlightedIndex(displayProjects.bookmarked.length + displayProjects.recent.length);
} else {
setHighlightedIndex(-1);
}
} else if (isOpen && !searchQuery.trim()) {
setHighlightedIndex(-1);
}
}, [isOpen, searchQuery]);
}, [isOpen, searchQuery, exactMatch, displayProjects]);
// Scroll highlighted item into view for keyboard navigation
useEffect(() => {
if (highlightedIndex < 0) return;
const el = itemRefs.current.get(highlightedIndex);
if (el) {
el.scrollIntoView({ block: "nearest" });
}
}, [highlightedIndex]);
// Handle project selection
const handleSelectProject = useCallback(
@@ -207,11 +313,14 @@ export function ProjectSelector({
// Toggle dropdown
const toggleDropdown = useCallback(() => {
setIsOpen((prev) => !prev);
if (isOpen) {
setSearchQuery("");
}
}, [isOpen]);
setIsOpen((prev) => {
if (!prev) {
// Opening — always clear search for a fresh type-ahead
setSearchQuery("");
}
return !prev;
});
}, []);
// Render status icon
const renderStatusIcon = (status: ProjectStatus) => {
@@ -226,6 +335,36 @@ export function ProjectSelector({
);
};
// Render bookmark star toggle (span to avoid nested <button> inside listbox items)
const renderBookmarkToggle = (projectId: string) => {
const bookmarked = isBookmarked(projectId);
return (
<span
role="button"
tabIndex={0}
className={`project-selector__bookmark ${bookmarked ? "bookmarked" : ""}`}
onClick={(e) => {
e.stopPropagation();
toggleBookmark(projectId);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
e.preventDefault();
toggleBookmark(projectId);
}
}}
aria-label={bookmarked ? t("projectSelector.removeBookmark", "Remove bookmark") : t("projectSelector.addBookmark", "Bookmark project")}
data-testid={`bookmark-toggle-${projectId}`}
>
<Star
size={14}
fill={bookmarked ? "currentColor" : "none"}
/>
</span>
);
};
// Don't render if only one project (single-project mode)
if (projects.length <= 1) {
return null;
@@ -262,40 +401,51 @@ export function ProjectSelector({
onKeyDown={handleDropdownKeyDown}
data-testid="project-selector-dropdown"
>
{/* Search input (shown when 5+ projects) */}
{projects.length >= 5 && (
<div className="project-selector__search">
<Search size={14} className="project-selector__search-icon" />
<input
ref={searchInputRef}
type="text"
placeholder={t("projectSelector.searchPlaceholder", "Search projects...")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="project-selector__search-input"
/>
{searchQuery && (
<button
className="project-selector__search-clear"
onClick={() => setSearchQuery("")}
aria-label={t("projectSelector.clearSearch", "Clear search")}
>
<X size={12} />
</button>
)}
{/* Search input — always visible for autocomplete/type-ahead */}
<div className="project-selector__search">
<Search size={14} className="project-selector__search-icon" />
<input
ref={searchInputRef}
type="text"
placeholder={t("projectSelector.searchPlaceholder", "Search projects...")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="project-selector__search-input"
data-testid="project-selector-search-input"
aria-label={t("projectSelector.searchAriaLabel", "Type to search projects")}
/>
{searchQuery && (
<button
className="project-selector__search-clear"
onClick={() => setSearchQuery("")}
aria-label={t("projectSelector.clearSearch", "Clear search")}
>
<X size={12} />
</button>
)}
</div>
{/* Exact match indicator */}
{exactMatch && (
<div className="project-selector__exact-match" data-testid="project-selector-exact-match">
{t("projectSelector.exactMatch", "Exact match — press Enter to select")}
</div>
)}
{/* Recent projects section */}
{displayProjects.recent.length > 0 && (
{/* Bookmarked projects section */}
{displayProjects.bookmarked.length > 0 && (
<div className="project-selector__section">
<div className="project-selector__section-header">
<Clock size={12} />
<span>{t("projectSelector.recent", "Recent")}</span>
<Star size={12} fill="currentColor" />
<span>{t("projectSelector.bookmarked", "Bookmarked")}</span>
</div>
{displayProjects.recent.map((project, index) => (
{displayProjects.bookmarked.map((project, index) => (
<button
key={project.id}
ref={(el) => {
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
}}
className={`project-selector__item ${
highlightedIndex === index ? "highlighted" : ""
}`}
@@ -307,6 +457,7 @@ export function ProjectSelector({
<span className="project-selector__item-name">
{project.name}
</span>
{renderBookmarkToggle(project.id)}
{currentProject?.id === project.id && (
<Check size={14} className="project-selector__item-check" />
)}
@@ -315,41 +466,98 @@ export function ProjectSelector({
</div>
)}
{/* All projects section */}
<div className="project-selector__section">
{displayProjects.recent.length > 0 && (
{/* Recent projects section */}
{displayProjects.recent.length > 0 && (
<div className="project-selector__section">
<div className="project-selector__section-header">
<Folder size={12} />
<span>{t("projectSelector.allProjects", "All Projects")}</span>
<Clock size={12} />
<span>{t("projectSelector.recent", "Recent")}</span>
</div>
)}
{displayProjects.others.length === 0 && searchQuery ? (
<div className="project-selector__no-results">
{t("projectSelector.noResults", "No projects match your search")}
</div>
) : (
displayProjects.others.map((project, index) => {
const actualIndex = displayProjects.recent.length + index;
{displayProjects.recent.map((project, index) => {
const actualIndex = displayProjects.bookmarked.length + index;
return (
<button
key={project.id}
ref={(el) => {
if (el) itemRefs.current.set(actualIndex, el);
else itemRefs.current.delete(actualIndex);
}}
className={`project-selector__item ${
highlightedIndex === actualIndex ? "highlighted" : ""
}`}
onClick={() => handleSelectProject(project)}
role="option"
aria-selected={currentProject?.id === project.id}
>
{renderStatusIcon(project.status)}
<span className="project-selector__item-name">
{project.name}
</span>
{renderBookmarkToggle(project.id)}
{currentProject?.id === project.id && (
<Check size={14} className="project-selector__item-check" />
)}
</button>
);
})}
</div>
)}
{/* All projects section */}
<div className="project-selector__section">
{(displayProjects.bookmarked.length > 0 || displayProjects.recent.length > 0) && (
<div className="project-selector__section-header">
<Folder size={12} />
<span>{t("projectSelector.allProjects", "All Projects")}</span>
</div>
)}
{displayProjects.others.length === 0 && searchQuery ? (
<div className="project-selector__no-results" data-testid="project-selector-no-results">
<Search size={14} className="project-selector__no-results-icon" />
<span>
{t("projectSelector.noResults", "No projects match \"{{query}}\"", { query: searchQuery })}
</span>
</div>
) : (
displayProjects.others.map((project, index) => {
const actualIndex = displayProjects.bookmarked.length + displayProjects.recent.length + index;
const isExactMatch = exactMatch?.id === project.id;
return (
<button
key={project.id}
ref={(el) => {
if (el) itemRefs.current.set(actualIndex, el);
else itemRefs.current.delete(actualIndex);
}}
className={`project-selector__item ${
highlightedIndex === actualIndex ? "highlighted" : ""
} ${isExactMatch ? "exact-match" : ""}`}
onClick={() => handleSelectProject(project)}
role="option"
aria-selected={currentProject?.id === project.id}
data-testid={`project-selector-item-${project.id}`}
>
{renderStatusIcon(project.status)}
<div className="project-selector__item-info">
<span className="project-selector__item-name">
{project.name}
</span>
<span className="project-selector__item-path">
{getTrailingPath(project.path, 2)}
<HighlightMatch text={project.name} query={searchQuery} />
</span>
{project.path && (
<span className="project-selector__item-path">
<HighlightMatch
text={getTrailingPath(project.path, 2)}
query={searchQuery}
/>
</span>
)}
</div>
{isExactMatch && (
<span className="project-selector__exact-badge">
{t("projectSelector.exact", "Exact")}
</span>
)}
{renderBookmarkToggle(project.id)}
{currentProject?.id === project.id && (
<Check size={14} className="project-selector__item-check" />
)}
@@ -362,6 +570,11 @@ export function ProjectSelector({
{/* View All option */}
<div className="project-selector__footer">
<button
ref={(el) => {
const viewAllIndex = totalItems - 1;
if (el) itemRefs.current.set(viewAllIndex, el);
else itemRefs.current.delete(viewAllIndex);
}}
className={`project-selector__view-all ${
highlightedIndex === totalItems - 1 ? "highlighted" : ""
}`}

View File

@@ -3,6 +3,18 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { ProjectSelector } from "../ProjectSelector";
import type { ProjectInfo, ProjectStatus } from "@fusion/core";
// Mock useProjectBookmarks
const mockToggleBookmark = vi.fn();
let mockBookmarkedIds: Set<string> = new Set();
vi.mock("../../hooks/useProjectBookmarks", () => ({
useProjectBookmarks: () => ({
bookmarkedIds: mockBookmarkedIds,
toggleBookmark: mockToggleBookmark,
isBookmarked: (id: string) => mockBookmarkedIds.has(id),
}),
}));
// Mock lucide-react
vi.mock("lucide-react", async () => {
const actual = await vi.importActual("lucide-react");
@@ -14,6 +26,9 @@ vi.mock("lucide-react", async () => {
Grid3X3: () => <span data-testid="grid-icon">⊞</span>,
Search: () => <span data-testid="search-icon">🔍</span>,
Clock: () => <span data-testid="clock-icon">🕐</span>,
Star: ({ fill }: { fill?: string }) => (
<span data-testid="star-icon" data-fill={fill ?? "none"}>★</span>
),
X: () => <span data-testid="x-icon">✕</span>,
Play: () => <span data-testid="play-icon">▶</span>,
Pause: () => <span data-testid="pause-icon">⏸</span>,
@@ -41,6 +56,9 @@ const noop = () => {};
describe("ProjectSelector", () => {
beforeEach(() => {
vi.clearAllMocks();
mockBookmarkedIds = new Set();
// JSDOM does not implement scrollIntoView — mock it for itemRefs
Element.prototype.scrollIntoView = vi.fn();
});
it("renders without crashing", () => {
@@ -231,14 +249,12 @@ describe("ProjectSelector", () => {
expect(screen.getByPlaceholderText("Search projects...")).toBeDefined();
});
it("does not show search input when fewer than 5 projects", () => {
it("shows search input even with only 2 projects (autocomplete always available)", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1" }),
makeProject({ id: "proj_2" }),
makeProject({ id: "proj_3" }),
makeProject({ id: "proj_4" }),
makeProject({ id: "proj_1", name: "Alpha" }),
makeProject({ id: "proj_2", name: "Beta" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
@@ -247,7 +263,8 @@ describe("ProjectSelector", () => {
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
expect(screen.queryByPlaceholderText("Search projects...")).toBeNull();
// Search input should always be present for autocomplete
expect(screen.getByTestId("project-selector-search-input")).toBeDefined();
});
it("filters projects based on search query", () => {
@@ -370,4 +387,421 @@ describe("ProjectSelector", () => {
fireEvent.click(screen.getByTestId("project-selector-trigger"));
// Should have checkmark for current project (though in dropdown it might not be visible due to filtering)
});
// === Bookmark-specific tests ===
describe("bookmarks", () => {
it("shows bookmark toggle on each project item", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const bookmarkToggle = screen.getByTestId("bookmark-toggle-proj_2");
expect(bookmarkToggle).toBeDefined();
});
it("calls toggleBookmark when star is clicked", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const bookmarkToggle = screen.getByTestId("bookmark-toggle-proj_2");
fireEvent.click(bookmarkToggle);
expect(mockToggleBookmark).toHaveBeenCalledWith("proj_2");
});
it("does not trigger onSelect when star is clicked", () => {
const onSelect = vi.fn();
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={onSelect}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const bookmarkToggle = screen.getByTestId("bookmark-toggle-proj_2");
fireEvent.click(bookmarkToggle);
expect(onSelect).not.toHaveBeenCalled();
});
it("shows Bookmarked section when projects are bookmarked", () => {
mockBookmarkedIds = new Set(["proj_2"]);
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
makeProject({ id: "proj_3", name: "Project Three" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
expect(screen.getByText("Bookmarked")).toBeDefined();
expect(screen.getByText("Project Two")).toBeDefined();
});
it("displays bookmarked projects at top of list", () => {
mockBookmarkedIds = new Set(["proj_3"]);
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
makeProject({ id: "proj_3", name: "Project Three" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
// "Bookmarked" section header should exist
expect(screen.getByText("Bookmarked")).toBeDefined();
// The "All Projects" section should also exist since we have non-bookmarked items
expect(screen.getByText("All Projects")).toBeDefined();
});
it("bookmark toggle shows filled star for bookmarked projects", () => {
mockBookmarkedIds = new Set(["proj_2"]);
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const bookmarkedStar = screen.getByTestId("bookmark-toggle-proj_2");
// Star inside should have data-fill="currentColor" for bookmarked
const starIcon = bookmarkedStar.querySelector('[data-testid="star-icon"]');
expect(starIcon?.getAttribute("data-fill")).toBe("currentColor");
});
it("does not show Bookmarked section when no projects are bookmarked", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
expect(screen.queryByText("Bookmarked")).toBeNull();
});
it("does not show current project in bookmarked section", () => {
mockBookmarkedIds = new Set(["proj_1"]);
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
// Current project should not appear in Bookmarked section
// (it's excluded from all lists)
expect(screen.queryByText("Bookmarked")).toBeNull();
});
it("bookmark toggle has correct aria-label", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const toggle = screen.getByTestId("bookmark-toggle-proj_2");
expect(toggle.getAttribute("aria-label")).toBe("Bookmark project");
});
it("bookmarked project toggle shows remove aria-label", () => {
mockBookmarkedIds = new Set(["proj_2"]);
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const toggle = screen.getByTestId("bookmark-toggle-proj_2");
expect(toggle.getAttribute("aria-label")).toBe("Remove bookmark");
});
it("dropdown stays open when bookmark is toggled", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project One" }),
makeProject({ id: "proj_2", name: "Project Two" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const bookmarkToggle = screen.getByTestId("bookmark-toggle-proj_2");
fireEvent.click(bookmarkToggle);
// Dropdown should still be open
expect(screen.getByTestId("project-selector-dropdown")).toBeDefined();
});
});
// === Autocomplete-specific tests ===
describe("autocomplete", () => {
it("highlights matching text in project names", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Project Alpha" }),
makeProject({ id: "proj_2", name: "Project Beta" }),
makeProject({ id: "proj_3", name: "Unrelated" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const searchInput = screen.getByPlaceholderText("Search projects...");
fireEvent.change(searchInput, { target: { value: "Beta" } });
// Should show the matched project with highlighted text
const mark = screen.getByText("Beta");
expect(mark.tagName).toBe("MARK");
expect(mark.closest(".project-selector__item")).toBeDefined();
});
it("detects exact match and shows indicator", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "My App" }),
makeProject({ id: "proj_2", name: "My Application" }),
makeProject({ id: "proj_3", name: "Other" }),
]}
currentProject={makeProject({ id: "proj_3" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const searchInput = screen.getByPlaceholderText("Search projects...");
fireEvent.change(searchInput, { target: { value: "My App" } });
// Should show exact match indicator
expect(screen.getByTestId("project-selector-exact-match")).toBeDefined();
// Should show "Exact" badge on the matching item
expect(screen.getByText("Exact")).toBeDefined();
});
it("does not show exact match indicator for partial match", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "My App" }),
makeProject({ id: "proj_2", name: "My Application" }),
makeProject({ id: "proj_3", name: "Other" }),
]}
currentProject={makeProject({ id: "proj_3" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const searchInput = screen.getByPlaceholderText("Search projects...");
// "My" matches multiple projects — not an exact match
fireEvent.change(searchInput, { target: { value: "My" } });
expect(screen.queryByTestId("project-selector-exact-match")).toBeNull();
});
it("auto-selects exact match on Enter when nothing is highlighted", () => {
const onSelect = vi.fn();
const exactProject = makeProject({ id: "proj_1", name: "Unique Project" });
render(
<ProjectSelector
projects={[
exactProject,
makeProject({ id: "proj_2", name: "Other Project" }),
]}
currentProject={makeProject({ id: "proj_2" })}
onSelect={onSelect}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const searchInput = screen.getByPlaceholderText("Search projects...");
fireEvent.change(searchInput, { target: { value: "Unique Project" } });
// Press Enter without highlighting anything first
fireEvent.keyDown(screen.getByTestId("project-selector-dropdown"), { key: "Enter" });
expect(onSelect).toHaveBeenCalledWith(exactProject);
});
it("shows no results message when search matches nothing", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Alpha" }),
makeProject({ id: "proj_2", name: "Beta" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const searchInput = screen.getByPlaceholderText("Search projects...");
fireEvent.change(searchInput, { target: { value: "zzznonexistent" } });
expect(screen.getByTestId("project-selector-no-results")).toBeDefined();
});
it("clear button clears search query", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Alpha" }),
makeProject({ id: "proj_2", name: "Beta" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const searchInput = screen.getByPlaceholderText("Search projects...");
fireEvent.change(searchInput, { target: { value: "Alpha" } });
// Clear button should appear
const clearButton = screen.getByLabelText("Clear search");
expect(clearButton).toBeDefined();
fireEvent.click(clearButton);
expect(searchInput).toHaveValue("");
});
it("filters by project path as well as name", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Alpha", path: "/home/user/projects/frontend" }),
makeProject({ id: "proj_2", name: "Beta", path: "/home/user/projects/backend" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const searchInput = screen.getByPlaceholderText("Search projects...");
fireEvent.change(searchInput, { target: { value: "backend" } });
// Beta project should be visible (matched by path)
expect(screen.getByText("Beta")).toBeDefined();
// Alpha should be filtered out
expect(screen.queryByText("Alpha")).toBeNull();
});
it("resets search query when dropdown is closed and reopened", () => {
render(
<ProjectSelector
projects={[
makeProject({ id: "proj_1", name: "Alpha" }),
makeProject({ id: "proj_2", name: "Beta" }),
]}
currentProject={makeProject({ id: "proj_1" })}
onSelect={noop}
onViewAll={noop}
/>
);
// Open, type something, close with escape
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const searchInput = screen.getByPlaceholderText("Search projects...");
fireEvent.change(searchInput, { target: { value: "Alpha" } });
fireEvent.keyDown(document, { key: "Escape" });
// Reopen — search should be cleared
fireEvent.click(screen.getByTestId("project-selector-trigger"));
const reopenedSearchInput = screen.getByPlaceholderText("Search projects...");
expect(reopenedSearchInput).toHaveValue("");
});
});
});

View File

@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useProjectBookmarks } from "../useProjectBookmarks";
describe("useProjectBookmarks", () => {
beforeEach(() => {
localStorage.clear();
});
it("starts with empty bookmarks when localStorage is empty", () => {
const { result } = renderHook(() => useProjectBookmarks());
expect(result.current.bookmarkedIds.size).toBe(0);
expect(result.current.isBookmarked("any-id")).toBe(false);
});
it("loads bookmarks from localStorage", () => {
localStorage.setItem("fusion_project_bookmarks", JSON.stringify(["proj_1", "proj_2"]));
const { result } = renderHook(() => useProjectBookmarks());
expect(result.current.bookmarkedIds.size).toBe(2);
expect(result.current.isBookmarked("proj_1")).toBe(true);
expect(result.current.isBookmarked("proj_2")).toBe(true);
expect(result.current.isBookmarked("proj_3")).toBe(false);
});
it("adds a bookmark", () => {
const { result } = renderHook(() => useProjectBookmarks());
act(() => {
result.current.toggleBookmark("proj_1");
});
expect(result.current.isBookmarked("proj_1")).toBe(true);
expect(result.current.bookmarkedIds.has("proj_1")).toBe(true);
});
it("removes a bookmark", () => {
localStorage.setItem("fusion_project_bookmarks", JSON.stringify(["proj_1"]));
const { result } = renderHook(() => useProjectBookmarks());
act(() => {
result.current.toggleBookmark("proj_1");
});
expect(result.current.isBookmarked("proj_1")).toBe(false);
expect(result.current.bookmarkedIds.size).toBe(0);
});
it("persists bookmarks to localStorage", () => {
const { result } = renderHook(() => useProjectBookmarks());
act(() => {
result.current.toggleBookmark("proj_1");
});
// Check that localStorage was updated
const stored = JSON.parse(localStorage.getItem("fusion_project_bookmarks") ?? "[]");
expect(stored).toContain("proj_1");
});
it("persists removal to localStorage", () => {
localStorage.setItem("fusion_project_bookmarks", JSON.stringify(["proj_1", "proj_2"]));
const { result } = renderHook(() => useProjectBookmarks());
act(() => {
result.current.toggleBookmark("proj_1");
});
const stored = JSON.parse(localStorage.getItem("fusion_project_bookmarks") ?? "[]");
expect(stored).toEqual(["proj_2"]);
});
it("handles corrupted localStorage gracefully", () => {
localStorage.setItem("fusion_project_bookmarks", "not-json{{{");
const { result } = renderHook(() => useProjectBookmarks());
expect(result.current.bookmarkedIds.size).toBe(0);
});
it("handles non-array localStorage value gracefully", () => {
localStorage.setItem("fusion_project_bookmarks", JSON.stringify({ foo: "bar" }));
const { result } = renderHook(() => useProjectBookmarks());
expect(result.current.bookmarkedIds.size).toBe(0);
});
it("filters non-string items from stored array", () => {
localStorage.setItem("fusion_project_bookmarks", JSON.stringify(["proj_1", 42, null, "proj_2"]));
const { result } = renderHook(() => useProjectBookmarks());
expect(result.current.bookmarkedIds.size).toBe(2);
expect(result.current.isBookmarked("proj_1")).toBe(true);
expect(result.current.isBookmarked("proj_2")).toBe(true);
});
it("toggles multiple bookmarks independently", () => {
const { result } = renderHook(() => useProjectBookmarks());
act(() => {
result.current.toggleBookmark("proj_1");
});
act(() => {
result.current.toggleBookmark("proj_2");
});
expect(result.current.isBookmarked("proj_1")).toBe(true);
expect(result.current.isBookmarked("proj_2")).toBe(true);
expect(result.current.bookmarkedIds.size).toBe(2);
act(() => {
result.current.toggleBookmark("proj_1");
});
expect(result.current.isBookmarked("proj_1")).toBe(false);
expect(result.current.isBookmarked("proj_2")).toBe(true);
expect(result.current.bookmarkedIds.size).toBe(1);
});
});

View File

@@ -0,0 +1,52 @@
import { useCallback, useState, useEffect } from "react";
const STORAGE_KEY = "fusion_project_bookmarks";
/**
* Manages project bookmark IDs persisted in localStorage.
* Bookmarks are stored as a JSON array of project ID strings.
*/
export function useProjectBookmarks() {
const [bookmarkedIds, setBookmarkedIds] = useState<Set<string>>(() => {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return new Set(parsed.filter((id: unknown) => typeof id === "string"));
}
}
} catch {
// Corrupted or missing — start empty
}
return new Set<string>();
});
// Sync to localStorage whenever the set changes
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...bookmarkedIds]));
} catch {
// localStorage full or unavailable — non-critical, ignore
}
}, [bookmarkedIds]);
const toggleBookmark = useCallback((projectId: string) => {
setBookmarkedIds((prev) => {
const next = new Set(prev);
if (next.has(projectId)) {
next.delete(projectId);
} else {
next.add(projectId);
}
return next;
});
}, []);
const isBookmarked = useCallback(
(projectId: string) => bookmarkedIds.has(projectId),
[bookmarkedIds],
);
return { bookmarkedIds, toggleBookmark, isBookmarked };
}

View File

@@ -0,0 +1,621 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
withRetry,
withRetryResult,
computeBackoff,
cancellableSleep,
type JitterStrategy,
type RetryOptions,
} from "../retry-with-backoff.js";
import {
EngineError,
TransientError,
NetworkError,
ServiceUnavailableError,
TimeoutError,
PermanentError,
ConfigurationError,
ValidationError,
RateLimitError,
classifyThrownError,
isRetryableError,
} from "../engine-errors.js";
// ── engine-errors.ts tests ──────────────────────────────────────────────
describe("engine-errors", () => {
describe("error hierarchy", () => {
it("TransientError is retryable EngineError", () => {
const err = new TransientError("blip");
expect(err).toBeInstanceOf(EngineError);
expect(err).toBeInstanceOf(TransientError);
expect(err.retryable).toBe(true);
expect(err.code).toBe("TRANSIENT");
expect(err.message).toBe("blip");
});
it("NetworkError is a TransientError", () => {
const err = new NetworkError("ECONNREFUSED");
expect(err).toBeInstanceOf(TransientError);
expect(err).toBeInstanceOf(NetworkError);
expect(err.retryable).toBe(true);
expect(err.code).toBe("NETWORK");
});
it("ServiceUnavailableError carries statusCode", () => {
const err = new ServiceUnavailableError("overloaded", 503);
expect(err).toBeInstanceOf(TransientError);
expect(err.statusCode).toBe(503);
expect(err.details?.statusCode).toBe(503);
});
it("TimeoutError carries timeoutMs", () => {
const err = new TimeoutError("timed out", 5000);
expect(err).toBeInstanceOf(TransientError);
expect(err.timeoutMs).toBe(5000);
});
it("PermanentError is non-retryable", () => {
const err = new PermanentError("bad code");
expect(err).toBeInstanceOf(EngineError);
expect(err.retryable).toBe(false);
expect(err.code).toBe("PERMANENT");
});
it("ConfigurationError is a PermanentError", () => {
const err = new ConfigurationError("missing API key");
expect(err).toBeInstanceOf(PermanentError);
expect(err.code).toBe("CONFIGURATION");
});
it("ValidationError is a PermanentError", () => {
const err = new ValidationError("invalid input");
expect(err).toBeInstanceOf(PermanentError);
expect(err.code).toBe("VALIDATION");
});
it("RateLimitError is non-retryable EngineError", () => {
const err = new RateLimitError("429", 5000);
expect(err).toBeInstanceOf(EngineError);
expect(err.retryable).toBe(false);
expect(err.code).toBe("RATE_LIMIT");
expect(err.retryAfterMs).toBe(5000);
});
it("error cause chain is preserved", () => {
const cause = new Error("root cause");
const err = new NetworkError("wrapped", undefined, cause);
expect(err.cause).toBe(cause);
});
});
describe("classifyThrownError", () => {
it("passes through existing EngineError instances", () => {
const original = new NetworkError("existing");
expect(classifyThrownError(original)).toBe(original);
});
it("classifies rate-limit errors", () => {
const err = classifyThrownError(new Error("rate limit exceeded"));
expect(err).toBeInstanceOf(RateLimitError);
});
it("classifies network errors", () => {
const err = classifyThrownError(new Error("ECONNREFUSED 127.0.0.1:443"));
expect(err).toBeInstanceOf(NetworkError);
});
it("classifies timeout errors", () => {
const err = classifyThrownError(new Error("ETIMEDOUT connection timed out"));
expect(err).toBeInstanceOf(TimeoutError);
});
it("classifies upstream service errors", () => {
const err = classifyThrownError(new Error("upstream connect error"));
expect(err).toBeInstanceOf(ServiceUnavailableError);
});
it("classifies server_error JSON payloads", () => {
const err = classifyThrownError(new Error('{"type":"server_error","code":"server_error"}'));
expect(err).toBeInstanceOf(ServiceUnavailableError);
});
it("classifies WebSocket errors as transient", () => {
const err = classifyThrownError(new Error("WebSocket error"));
expect(err).toBeInstanceOf(TransientError);
expect(err.retryable).toBe(true);
});
it("classifies unknown errors as permanent", () => {
const err = classifyThrownError(new Error("something unexpected"));
expect(err).toBeInstanceOf(PermanentError);
expect(err.code).toBe("UNKNOWN");
});
it("handles string thrown values", () => {
const err = classifyThrownError("plain string error");
expect(err).toBeInstanceOf(PermanentError);
});
it("handles null/undefined thrown values", () => {
const err = classifyThrownError(null);
expect(err).toBeInstanceOf(PermanentError);
});
});
describe("isRetryableError", () => {
it("returns true for TransientError subclasses", () => {
expect(isRetryableError(new NetworkError("net"))).toBe(true);
expect(isRetryableError(new ServiceUnavailableError("svc"))).toBe(true);
expect(isRetryableError(new TimeoutError("tmr"))).toBe(true);
expect(isRetryableError(new TransientError("gen"))).toBe(true);
});
it("returns false for PermanentError", () => {
expect(isRetryableError(new PermanentError("perm"))).toBe(false);
});
it("returns false for RateLimitError", () => {
expect(isRetryableError(new RateLimitError("rl"))).toBe(false);
});
it("falls back to string detection for untyped errors", () => {
expect(isRetryableError(new Error("ECONNREFUSED"))).toBe(true);
expect(isRetryableError(new Error("socket hang up"))).toBe(true);
expect(isRetryableError(new Error("bad code"))).toBe(false);
});
});
});
// ── retry-with-backoff.ts tests ─────────────────────────────────────────
describe("computeBackoff", () => {
it("returns raw delay with jitter=none", () => {
expect(computeBackoff(0, 1000, 30000, "none")).toBe(1000);
expect(computeBackoff(1, 1000, 30000, "none")).toBe(2000);
expect(computeBackoff(2, 1000, 30000, "none")).toBe(4000);
expect(computeBackoff(3, 1000, 30000, "none")).toBe(8000);
});
it("caps delay at maxDelayMs", () => {
expect(computeBackoff(10, 1000, 5000, "none")).toBe(5000);
});
it("full jitter returns value in [0, rawDelay]", () => {
vi.spyOn(Math, "random").mockReturnValue(0.5);
const delay = computeBackoff(0, 1000, 30000, "full");
expect(delay).toBe(500); // floor(0.5 * 1000)
vi.restoreAllMocks();
});
it("equal jitter returns value in [rawDelay/2, rawDelay]", () => {
vi.spyOn(Math, "random").mockReturnValue(0.5);
const delay = computeBackoff(0, 1000, 30000, "equal");
expect(delay).toBe(750); // floor(500 + 0.5 * 500)
vi.restoreAllMocks();
});
it("exponential growth is correct with no jitter", () => {
const delays = [0, 1, 2, 3, 4].map((a) => computeBackoff(a, 500, 100000, "none"));
expect(delays).toEqual([500, 1000, 2000, 4000, 8000]);
});
});
describe("cancellableSleep", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("resolves after the specified delay", async () => {
const promise = cancellableSleep(1000);
await vi.advanceTimersByTimeAsync(1000);
await expect(promise).resolves.toBeUndefined();
});
it("rejects immediately if signal is already aborted", async () => {
const ac = new AbortController();
ac.abort(new Error("Already done"));
await expect(cancellableSleep(1000, ac.signal)).rejects.toThrow("Already done");
});
it("rejects when signal fires during sleep", async () => {
const ac = new AbortController();
const promise = cancellableSleep(10000, ac.signal);
await vi.advanceTimersByTimeAsync(100);
ac.abort(new Error("Cancelled"));
await expect(promise).rejects.toThrow("Cancelled");
});
});
describe("withRetry", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("returns the result when fn succeeds on first call", async () => {
const fn = vi.fn().mockResolvedValue("ok");
const result = await withRetry(fn);
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(1);
});
it("retries on TransientError and succeeds", async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new NetworkError("ECONNREFUSED"))
.mockResolvedValueOnce("recovered");
const onRetry = vi.fn();
const promise = withRetry(fn, {
baseDelayMs: 100,
maxDelayMs: 1000,
jitter: "none",
onRetry,
});
await vi.advanceTimersByTimeAsync(200);
const result = await promise;
expect(result).toBe("recovered");
expect(fn).toHaveBeenCalledTimes(2);
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry).toHaveBeenCalledWith(1, 100, expect.any(NetworkError));
});
it("retries on raw transient error strings (untyped)", async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error("socket hang up"))
.mockResolvedValueOnce("ok");
const promise = withRetry(fn, {
baseDelayMs: 100,
maxDelayMs: 1000,
jitter: "none",
});
await vi.advanceTimersByTimeAsync(200);
const result = await promise;
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(2);
});
it("re-throws non-retryable errors immediately without retry", async () => {
const fn = vi.fn().mockRejectedValue(new Error("ENOENT: file not found"));
const onRetry = vi.fn();
await expect(
withRetry(fn, { baseDelayMs: 100, onRetry }),
).rejects.toThrow("ENOENT: file not found");
expect(fn).toHaveBeenCalledTimes(1);
expect(onRetry).not.toHaveBeenCalled();
});
it("re-throws PermanentError immediately", async () => {
const fn = vi.fn().mockRejectedValue(new PermanentError("bad config"));
const onRetry = vi.fn();
await expect(
withRetry(fn, { baseDelayMs: 100, onRetry }),
).rejects.toThrow("bad config");
expect(fn).toHaveBeenCalledTimes(1);
expect(onRetry).not.toHaveBeenCalled();
});
it("never retries rate-limit errors", async () => {
const fn = vi.fn().mockRejectedValue(new Error("rate limit exceeded"));
const onRetry = vi.fn();
await expect(
withRetry(fn, { baseDelayMs: 100, onRetry }),
).rejects.toThrow("rate limit exceeded");
expect(fn).toHaveBeenCalledTimes(1);
expect(onRetry).not.toHaveBeenCalled();
});
it("never retries RateLimitError instances", async () => {
const fn = vi.fn().mockRejectedValue(new RateLimitError("429"));
const onRetry = vi.fn();
await expect(
withRetry(fn, { baseDelayMs: 100, onRetry }),
).rejects.toThrow("429");
expect(fn).toHaveBeenCalledTimes(1);
expect(onRetry).not.toHaveBeenCalled();
});
it("applies exponential backoff with increasing delays", async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new NetworkError("net-1"))
.mockRejectedValueOnce(new NetworkError("net-2"))
.mockResolvedValueOnce("ok");
const delays: number[] = [];
const onRetry = (_attempt: number, delayMs: number) => delays.push(delayMs);
const promise = withRetry(fn, {
baseDelayMs: 1000,
maxDelayMs: 10000,
jitter: "none",
onRetry,
});
await vi.advanceTimersByTimeAsync(1100); // 1st delay: 1000ms
await vi.advanceTimersByTimeAsync(2100); // 2nd delay: 2000ms
await promise;
expect(delays).toEqual([1000, 2000]);
expect(fn).toHaveBeenCalledTimes(3);
});
it("caps delay at maxDelayMs", async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new TimeoutError("slow"))
.mockResolvedValueOnce("ok");
const delays: number[] = [];
const promise = withRetry(fn, {
baseDelayMs: 100000,
maxDelayMs: 5000,
jitter: "none",
onRetry: (_a, d) => delays.push(d),
});
await vi.advanceTimersByTimeAsync(6000);
await promise;
expect(delays[0]).toBe(5000);
});
it("throws after all retries are exhausted", async () => {
const fn = vi.fn().mockRejectedValue(new NetworkError("always fails"));
const onRetry = vi.fn();
const promise = withRetry(fn, {
maxRetries: 2,
baseDelayMs: 100,
maxDelayMs: 1000,
jitter: "none",
onRetry,
});
const assertion = expect(promise).rejects.toThrow("always fails");
for (let i = 0; i < 10; i++) {
await vi.advanceTimersByTimeAsync(500);
}
await assertion;
expect(fn).toHaveBeenCalledTimes(3); // initial + 2 retries
expect(onRetry).toHaveBeenCalledTimes(2);
});
it("cancels backoff sleep when abort signal fires", async () => {
const fn = vi.fn().mockRejectedValue(new NetworkError("net"));
const ac = new AbortController();
const promise = withRetry(fn, {
baseDelayMs: 60000,
maxDelayMs: 120000,
jitter: "none",
signal: ac.signal,
});
// Let first call fail and start sleeping
await vi.advanceTimersByTimeAsync(10);
ac.abort(new Error("Task paused"));
await expect(promise).rejects.toThrow("Task paused");
expect(fn).toHaveBeenCalledTimes(1);
});
it("does not retry if abort signal is already aborted at start", async () => {
const fn = vi.fn().mockRejectedValue(new NetworkError("net"));
const ac = new AbortController();
ac.abort(new Error("Already cancelled"));
await expect(
withRetry(fn, { signal: ac.signal }),
).rejects.toThrow("Aborted before first attempt");
expect(fn).toHaveBeenCalledTimes(0); // never called — aborted before first attempt
});
it("supports custom isRetryable check", async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new Error("custom-retryable"))
.mockResolvedValueOnce("ok");
const promise = withRetry(fn, {
baseDelayMs: 100,
maxDelayMs: 1000,
jitter: "none",
isRetryable: (err) => err instanceof Error && err.message === "custom-retryable",
});
await vi.advanceTimersByTimeAsync(200);
const result = await promise;
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(2);
});
it("custom isRetryable returning false prevents retry", async () => {
const fn = vi.fn().mockRejectedValue(new Error("custom-retryable"));
const onRetry = vi.fn();
await expect(
withRetry(fn, {
baseDelayMs: 100,
onRetry,
isRetryable: () => false,
}),
).rejects.toThrow("custom-retryable");
expect(fn).toHaveBeenCalledTimes(1);
expect(onRetry).not.toHaveBeenCalled();
});
it("handles simulated 5xx errors correctly", async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new ServiceUnavailableError("502 Bad Gateway", 502))
.mockRejectedValueOnce(new ServiceUnavailableError("503 Service Unavailable", 503))
.mockResolvedValueOnce("recovered");
const onRetry = vi.fn();
const promise = withRetry(fn, {
maxRetries: 3,
baseDelayMs: 100,
maxDelayMs: 5000,
jitter: "none",
onRetry,
});
await vi.advanceTimersByTimeAsync(200); // 1st delay: 100ms
await vi.advanceTimersByTimeAsync(400); // 2nd delay: 200ms
const result = await promise;
expect(result).toBe("recovered");
expect(fn).toHaveBeenCalledTimes(3);
expect(onRetry).toHaveBeenCalledTimes(2);
// Verify the errors carry status codes
const retryCalls = onRetry.mock.calls;
expect((retryCalls[0][2] as ServiceUnavailableError).statusCode).toBe(502);
expect((retryCalls[1][2] as ServiceUnavailableError).statusCode).toBe(503);
});
it("handles simulated timeout errors correctly", async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new TimeoutError("request timed out", 5000))
.mockResolvedValueOnce("ok");
const promise = withRetry(fn, {
baseDelayMs: 100,
maxDelayMs: 1000,
jitter: "none",
});
await vi.advanceTimersByTimeAsync(200);
const result = await promise;
expect(result).toBe("ok");
expect(fn).toHaveBeenCalledTimes(2);
});
it("non-retryable errors fail fast without blocking", async () => {
const fn = vi.fn().mockRejectedValue(new ValidationError("invalid schema"));
const onRetry = vi.fn();
const start = Date.now();
await expect(
withRetry(fn, { baseDelayMs: 10000, onRetry }),
).rejects.toThrow("invalid schema");
// Should resolve immediately — no sleep for non-retryable errors
// (fake timers don't advance real Date.now, but fn call count proves it)
expect(fn).toHaveBeenCalledTimes(1);
expect(onRetry).not.toHaveBeenCalled();
});
it("handles non-Error thrown values", async () => {
const fn = vi.fn().mockRejectedValue("string error");
const onRetry = vi.fn();
await expect(
withRetry(fn, { baseDelayMs: 100, onRetry }),
).rejects.toThrow("string error");
expect(fn).toHaveBeenCalledTimes(1);
expect(onRetry).not.toHaveBeenCalled();
});
it("uses default options when none provided", async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new NetworkError("net"))
.mockResolvedValueOnce("ok");
const promise = withRetry(fn);
// Default baseDelayMs=1000, jitter="full" → delay is random in [0,1000]
await vi.advanceTimersByTimeAsync(2000);
const result = await promise;
expect(result).toBe("ok");
});
it("respects per-attempt timeout", async () => {
// Simulate a slow operation that exceeds the per-attempt timeout.
// On first call, fn returns a promise that never settles (simulating a hang).
// On second call (after retry), fn resolves successfully.
const fn = vi
.fn()
.mockImplementationOnce(
() => new Promise(() => {}), // hangs forever — triggers timeout
)
.mockResolvedValueOnce("recovered");
const onRetry = vi.fn();
const promise = withRetry(fn, {
maxRetries: 1,
baseDelayMs: 100,
maxDelayMs: 1000,
jitter: "none",
timeoutMs: 500,
onRetry,
});
// Let first attempt timeout (500ms)
await vi.advanceTimersByTimeAsync(600);
// Let retry backoff pass (100ms)
await vi.advanceTimersByTimeAsync(200);
// Second attempt resolves immediately (fn is mockResolvedValueOnce)
const result = await promise;
expect(result).toBe("recovered");
expect(fn).toHaveBeenCalledTimes(2);
expect(onRetry).toHaveBeenCalledTimes(1);
// Verify the retry was triggered by a timeout classification
const retryErr = onRetry.mock.calls[0][2];
expect(retryErr.code).toBe("TIMEOUT");
});
});
describe("withRetryResult", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("returns metadata with retry count and elapsed time", async () => {
const fn = vi
.fn()
.mockRejectedValueOnce(new NetworkError("net"))
.mockResolvedValueOnce("ok");
const promise = withRetryResult(fn, {
baseDelayMs: 100,
maxDelayMs: 1000,
jitter: "none",
});
await vi.advanceTimersByTimeAsync(200);
const result = await promise;
expect(result.value).toBe("ok");
expect(result.retries).toBe(1);
expect(result.elapsedMs).toBeGreaterThanOrEqual(0);
});
it("reports 0 retries on first-attempt success", async () => {
const fn = vi.fn().mockResolvedValue("instant");
const result = await withRetryResult(fn);
expect(result.value).toBe("instant");
expect(result.retries).toBe(0);
});
});

View File

@@ -0,0 +1,269 @@
/**
* Structured Engine Error Types — domain-specific error classes for the Fusion engine.
*
* These error types replace generic `catch (err)` blocks with typed, classifiable
* errors that callers can match on for domain-specific handling (retry, fail-fast,
* alerting, etc.).
*
* ## Hierarchy
*
* ```
* EngineError (base)
* ├── TransientError — temporary, retryable (network blip, 5xx, timeout)
* │ ├── NetworkError — connection refused/reset, DNS failure, socket hang-up
* │ ├── ServiceUnavailableError — upstream 5xx, overloaded, maintenance mode
* │ └── TimeoutError — request/operation exceeded deadline
* ├── PermanentError — non-retryable, task-defect or config error
* │ ├── ConfigurationError — bad env, missing keys, invalid settings
* │ └── ValidationError — schema violations, invalid inputs
* └── RateLimitError — quota/rate-limit, needs global pause (not local retry)
* ```
*
* ## Usage
*
* ```ts
* catch (err) {
* if (err instanceof TransientError) {
* // Move task to todo for retry
* } else if (err instanceof RateLimitError) {
* // Trigger global usage-limit pause
* } else if (err instanceof PermanentError) {
* // Mark task as failed
* }
* }
* ```
*/
import { isUsageLimitError } from "./usage-limit-detector.js";
import { isTransientError } from "./transient-error-detector.js";
// ── Base Error ──────────────────────────────────────────────────────────
/**
* Base class for all structured engine errors.
*
* Adds a `code` (machine-readable string) and optional `cause` chain to the
* standard Error. Subclasses set `retryable` to indicate whether the operation
* should be retried by the caller.
*/
export abstract class EngineError extends Error {
/** Machine-readable error code for programmatic matching. */
public readonly code: string;
/** Whether the caller should retry the operation. */
public readonly retryable: boolean;
/** Optional structured metadata for logging/metrics. */
public readonly details?: Record<string, unknown>;
constructor(
message: string,
code: string,
retryable: boolean,
details?: Record<string, unknown>,
cause?: Error,
) {
super(message, { cause });
this.name = this.constructor.name;
this.code = code;
this.retryable = retryable;
this.details = details;
}
}
// ── Transient Errors (retryable) ────────────────────────────────────────
/**
* A transient error — the operation failed due to a temporary condition
* that is expected to resolve on its own (network blip, brief service
* unavailability, timeout). Callers should retry with backoff.
*/
export class TransientError extends EngineError {
constructor(
message: string,
code: string = "TRANSIENT",
details?: Record<string, unknown>,
cause?: Error,
) {
super(message, code, true, details, cause);
}
}
/**
* Network-level error — connection refused, DNS resolution failure,
* socket hang-up, TLS handshake failure, etc.
*/
export class NetworkError extends TransientError {
constructor(
message: string,
details?: Record<string, unknown>,
cause?: Error,
) {
super(message, "NETWORK", details, cause);
}
}
/**
* Upstream service returned a 5xx or is temporarily unavailable
* (overloaded, maintenance mode).
*/
export class ServiceUnavailableError extends TransientError {
/** HTTP status code if available. */
public readonly statusCode?: number;
constructor(
message: string,
statusCode?: number,
details?: Record<string, unknown>,
cause?: Error,
) {
super(message, "SERVICE_UNAVAILABLE", { ...details, statusCode }, cause);
this.statusCode = statusCode;
}
}
/**
* Operation or request exceeded its deadline / timeout.
*/
export class TimeoutError extends TransientError {
/** Configured timeout in milliseconds. */
public readonly timeoutMs?: number;
constructor(
message: string,
timeoutMs?: number,
details?: Record<string, unknown>,
cause?: Error,
) {
super(message, "TIMEOUT", { ...details, timeoutMs }, cause);
this.timeoutMs = timeoutMs;
}
}
// ── Permanent Errors (non-retryable) ────────────────────────────────────
/**
* A permanent error — the operation failed due to a defect in the task,
* configuration, or input. Retrying will not help.
*/
export class PermanentError extends EngineError {
constructor(
message: string,
code: string = "PERMANENT",
details?: Record<string, unknown>,
cause?: Error,
) {
super(message, code, false, details, cause);
}
}
/**
* Configuration error — missing env vars, invalid settings, bad keys.
*/
export class ConfigurationError extends PermanentError {
constructor(
message: string,
details?: Record<string, unknown>,
cause?: Error,
) {
super(message, "CONFIGURATION", details, cause);
}
}
/**
* Validation error — schema violations, invalid inputs, malformed data.
*/
export class ValidationError extends PermanentError {
constructor(
message: string,
details?: Record<string, unknown>,
cause?: Error,
) {
super(message, "VALIDATION", details, cause);
}
}
// ── Rate Limit Error (special — global pause, not local retry) ──────────
/**
* Rate-limit / usage-limit error. Unlike transient errors, these should
* NOT be retried locally — instead they trigger a global pause via
* UsageLimitPauser so all agents back off simultaneously.
*/
export class RateLimitError extends EngineError {
/** Suggested retry-after in milliseconds (from Retry-After header or heuristic). */
public readonly retryAfterMs?: number;
constructor(
message: string,
retryAfterMs?: number,
details?: Record<string, unknown>,
cause?: Error,
) {
super(message, "RATE_LIMIT", false, { ...details, retryAfterMs }, cause);
this.retryAfterMs = retryAfterMs;
}
}
// ── Classification helpers ──────────────────────────────────────────────
/**
* Classify a raw error into a structured EngineError subtype.
*
* This bridges the gap between legacy string-based error classification
* (transient-error-detector, usage-limit-detector) and the new typed system.
* New code should throw typed errors directly; this function upgrades
* untyped errors from external libraries.
*
* @param err - The raw thrown value
* @returns A structured EngineError instance
*/
export function classifyThrownError(err: unknown): EngineError {
// Already structured — return as-is
if (err instanceof EngineError) {
return err;
}
const message = err instanceof Error ? err.message : String(err ?? "");
// Rate-limit (triggers global pause)
if (isUsageLimitError(message)) {
return new RateLimitError(message, undefined, undefined, err instanceof Error ? err : undefined);
}
// Network / connection errors
if (/ECONNREFUSED|connection refused|connection reset|socket hang up|EHOSTUNREACH|ENETUNREACH/i.test(message)) {
return new NetworkError(message, undefined, err instanceof Error ? err : undefined);
}
// Timeout errors
if (/ETIMEDOUT|timeout.*connection|connection.*timeout|deadline exceeded|timed out after \d+ms/i.test(message)) {
return new TimeoutError(message, undefined, undefined, err instanceof Error ? err : undefined);
}
// 5xx / service unavailable
if (/upstream connect error|disconnect\/reset before headers|remote connection failure|transport failure/i.test(message)) {
return new ServiceUnavailableError(message, undefined, undefined, err instanceof Error ? err : undefined);
}
if (/"type":"server_error"|\"code\":\"server_error\"/i.test(message)) {
return new ServiceUnavailableError(message, 500, undefined, err instanceof Error ? err : undefined);
}
// Generic transient (WebSocket errors, provider aborts, etc.)
if (isTransientError(message)) {
return new TransientError(message, "TRANSIENT", undefined, err instanceof Error ? err : undefined);
}
// Default: permanent
return new PermanentError(message, "UNKNOWN", undefined, err instanceof Error ? err : undefined);
}
/**
* Type guard: is the error retryable (transient)?
*/
export function isRetryableError(err: unknown): err is TransientError {
if (err instanceof TransientError) return true;
if (err instanceof EngineError) return err.retryable;
// Fall back to string-based detection for untyped errors
const message = err instanceof Error ? err.message : String(err ?? "");
return isTransientError(message);
}

View File

@@ -0,0 +1,344 @@
/**
* Retry with Exponential Backoff — general-purpose retry wrapper for
* transient network and external service failures.
*
* This extends the retry pattern established in `rate-limit-retry.ts` to
* cover ALL transient errors (network blips, 5xx, timeouts, WebSocket drops)
* — not just rate-limit / usage-limit errors.
*
* ## Strategy
*
* **Backoff:** `delay = min(baseDelayMs × 2^attempt, maxDelayMs)` with
* configurable jitter to avoid thundering-herd effects across concurrent agents.
*
* **Jitter modes:**
* - `"full"` (default): `random(0, delay)` — spreads retries uniformly
* - `"equal"`: `base + random(0, base)` where `base = delay/2` — tighter clustering
* - `"none"`: no jitter — deterministic (useful for tests)
*
* **Retryable check:** Uses the structured error types from `engine-errors.ts`
* when available, falling back to `transient-error-detector.ts` for untyped errors.
*
* **Abort support:** An optional `AbortSignal` cancels pending retries when a
* task is paused, cancelled, or the engine shuts down.
*
* **Non-blocking:** Backoff sleeps yield to the event loop, never blocking the
* main thread.
*
* @example
* ```ts
* const result = await withRetry(() => fetchExternalService(url), {
* maxRetries: 3,
* baseDelayMs: 1000,
* maxDelayMs: 30_000,
* timeoutMs: 60_000,
* onRetry: (attempt, delayMs, err) => {
* logger.warn(`Retry ${attempt} after ${delayMs}ms: ${err.message}`);
* },
* signal: abortController.signal,
* });
* ```
*/
import { classifyThrownError, isRetryableError, type EngineError } from "./engine-errors.js";
import { isUsageLimitError } from "./usage-limit-detector.js";
// ── Types ───────────────────────────────────────────────────────────────
/** Jitter strategy for backoff delay randomization. */
export type JitterStrategy = "full" | "equal" | "none";
/** Configuration for retry behavior. */
export interface RetryOptions {
/** Maximum number of retry attempts before re-throwing (default: 3). */
maxRetries?: number;
/** Initial backoff delay in milliseconds (default: 1 000 — 1 s). */
baseDelayMs?: number;
/** Upper bound on backoff delay in milliseconds (default: 30 000 — 30 s). */
maxDelayMs?: number;
/**
* Per-attempt timeout in milliseconds. When set, each call to `fn()` is
* wrapped in a deadline. If the deadline fires before `fn()` resolves, a
* TimeoutError is thrown (which is retryable). Default: undefined (no timeout).
*/
timeoutMs?: number;
/**
* Jitter strategy for randomizing backoff delays (default: "full").
* - `"full"`: random(0, delay) — best spread, recommended for production
* - `"equal"`: base ± random(0, base/2) — tighter clustering
* - `"none"`: no jitter — deterministic, useful for tests
*/
jitter?: JitterStrategy;
/**
* Called before each retry with the attempt number (1-based), the
* computed delay, and the error that triggered the retry.
*/
onRetry?: (attempt: number, delayMs: number, error: EngineError) => void;
/**
* Abort signal that cancels pending retries and re-throws immediately.
*/
signal?: AbortSignal;
/**
* Custom retryable check. When provided, this function is called instead
* of the default `isRetryableError` check. Return `true` to retry, `false`
* to re-throw immediately.
*/
isRetryable?: (err: unknown) => boolean;
}
/** Result of a successful retry operation, including retry metadata. */
export interface RetryResult<T> {
/** The successful return value. */
value: T;
/** Total number of retries that occurred (0 = succeeded on first attempt). */
retries: number;
/** Total elapsed time in milliseconds including all backoff sleeps. */
elapsedMs: number;
}
// ── Backoff Calculation ─────────────────────────────────────────────────
/**
* Compute the backoff delay for a given attempt with the chosen jitter strategy.
*
* @param attempt - 0-based attempt index
* @param baseDelayMs - Base delay in milliseconds
* @param maxDelayMs - Maximum delay cap in milliseconds
* @param jitter - Jitter strategy
* @returns Delay in milliseconds (always >= 0)
*/
export function computeBackoff(
attempt: number,
baseDelayMs: number,
maxDelayMs: number,
jitter: JitterStrategy = "full",
): number {
const rawDelay = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
switch (jitter) {
case "full":
return Math.floor(Math.random() * rawDelay);
case "equal": {
const half = rawDelay / 2;
return Math.floor(half + Math.random() * half);
}
case "none":
return rawDelay;
}
}
// ── Sleep with Abort ────────────────────────────────────────────────────
/**
* Sleep for `ms` milliseconds, cancellable via an `AbortSignal`.
* Yields to the event loop — never blocks the main thread.
*/
export function cancellableSleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason ?? new Error("Aborted"));
return;
}
const timer = setTimeout(resolve, ms);
if (signal) {
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason ?? new Error("Aborted"));
};
signal.addEventListener("abort", onAbort, { once: true });
// Clean up listener when timer fires normally
const origResolve = resolve;
resolve = () => {
signal.removeEventListener("abort", onAbort);
origResolve();
};
}
});
}
// ── Timeout Wrapper ─────────────────────────────────────────────────────
/**
* Wrap an async function with a deadline timeout.
*
* If `fn()` does not settle within `timeoutMs`, the promise is rejected
* with a TimeoutError and any underlying resources are cleaned up via
* the AbortController.
*/
function withTimeout<T>(
fn: () => Promise<T>,
timeoutMs: number,
parentSignal?: AbortSignal,
): Promise<T> {
let settled = false;
return new Promise<T>((resolve, reject) => {
const ac = new AbortController();
// Link parent signal — if parent aborts, we abort too
const onParentAbort = () => {
if (settled) return;
settled = true;
ac.abort(parentSignal?.reason ?? new Error("Aborted"));
reject(parentSignal?.reason ?? new Error("Aborted"));
};
parentSignal?.addEventListener("abort", onParentAbort, { once: true });
const timer = setTimeout(() => {
if (settled) return;
settled = true;
ac.abort(new Error(`Operation timed out after ${timeoutMs}ms`));
reject(new Error(`Operation timed out after ${timeoutMs}ms`));
}, timeoutMs);
fn()
.then((result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
parentSignal?.removeEventListener("abort", onParentAbort);
resolve(result);
})
.catch((err: unknown) => {
if (settled) return;
settled = true;
clearTimeout(timer);
parentSignal?.removeEventListener("abort", onParentAbort);
reject(err);
});
});
}
// ── Main Retry Function ─────────────────────────────────────────────────
/**
* Wrap an async function with exponential backoff retry for transient errors.
*
* The wrapper calls `fn()`. If it throws a retryable error (transient network
* or service errors), it sleeps with exponential backoff and retries up to
* `maxRetries` times. Non-retryable errors are re-thrown immediately.
*
* Rate-limit / usage-limit errors are NEVER retried by this function — they
* should be handled by `withRateLimitRetry` or trigger a global pause.
*
* After all retries are exhausted, the original error is thrown.
*
* @param fn - The async function to execute
* @param options - Retry configuration
* @returns The return value of `fn()`
*/
export async function withRetry<T>(
fn: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const {
maxRetries = 3,
baseDelayMs = 1_000,
maxDelayMs = 30_000,
timeoutMs,
jitter = "full",
onRetry,
signal,
isRetryable: customIsRetryable,
} = options;
const startTime = Date.now();
let lastError: EngineError | undefined;
let retryCount = 0;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
// Check abort before each attempt
if (signal?.aborted) {
throw lastError ?? new Error("Aborted before first attempt");
}
try {
// Wrap with timeout if configured
const result = timeoutMs
? await withTimeout(fn, timeoutMs, signal)
: await fn();
return result;
} catch (err: unknown) {
// Classify the error into a structured type
const classified = classifyThrownError(err);
// Rate-limit errors: never retry locally — re-throw immediately
if (isUsageLimitError(classified.message)) {
throw classified;
}
// Use custom retryable check if provided, otherwise use default
const shouldRetry = customIsRetryable
? customIsRetryable(err)
: isRetryableError(classified);
// Non-retryable error: re-throw immediately
if (!shouldRetry) {
throw classified;
}
lastError = classified;
// All retries exhausted — throw the last error
if (attempt >= maxRetries) {
throw lastError;
}
// Check abort before sleeping
if (signal?.aborted) {
throw lastError;
}
// Compute backoff delay
const delay = computeBackoff(attempt, baseDelayMs, maxDelayMs, jitter);
onRetry?.(attempt + 1, delay, classified);
// Sleep with cancellation support
await cancellableSleep(delay, signal);
retryCount++;
}
}
// Unreachable, but satisfies TypeScript
throw lastError ?? new Error("withRetry: unexpected state");
}
/**
* Wrap an async function with retry and return extended metadata.
*
* Same as `withRetry` but returns a `RetryResult<T>` with the value plus
* retry count and elapsed time — useful for logging and metrics.
*
* @param fn - The async function to execute
* @param options - Retry configuration
* @returns A `RetryResult<T>` with value and retry metadata
*/
export async function withRetryResult<T>(
fn: () => Promise<T>,
options: RetryOptions = {},
): Promise<RetryResult<T>> {
const startTime = Date.now();
let retries = 0;
const result = await withRetry<T>(async () => {
if (retries > 0) {
// We're in a retry — count it
}
return fn();
}, {
...options,
onRetry: (attempt, delayMs, err) => {
retries = attempt;
options.onRetry?.(attempt, delayMs, err);
},
});
return {
value: result,
retries,
elapsedMs: Date.now() - startTime,
};
}