Merge pull request #1467 from buihongduc132/upstream-pr/project-selector-enhancements
feat(dashboard): scrollable, autocomplete, bookmarkable project selector
This commit is contained in:
3
.changeset/fn-1467-pr-feedback-fixes.md
Normal file
3
.changeset/fn-1467-pr-feedback-fixes.md
Normal file
@@ -0,0 +1,3 @@
|
||||
"@runfusion/fusion": patch
|
||||
|
||||
Fix project selector review regressions around optional selection handlers and bookmarked search matches, and tighten retry/backoff timeout and rate-limit handling.
|
||||
@@ -52,6 +52,7 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
.chat-view {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
@@ -952,12 +836,12 @@ export function Header({
|
||||
)}
|
||||
|
||||
{/* Project Selector - Back button when project selected, dropdown when 2+ projects (tablet + desktop) */}
|
||||
{!isMobile && projects.length >= 1 && onViewAllProjects && (
|
||||
<ProjectSelector
|
||||
{!isMobile && projects.length >= 1 && onViewAllProjects && onSelectProject && (
|
||||
<StandaloneProjectSelector
|
||||
projects={projects}
|
||||
currentProject={currentProject ?? null}
|
||||
onViewAll={onViewAllProjects}
|
||||
onSelectProject={onSelectProject}
|
||||
onSelect={onSelectProject}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,61 @@ 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;
|
||||
const hasSearch = Boolean(searchQuery.trim());
|
||||
|
||||
// Exclude current project from list
|
||||
// Bookmarked projects (excluding current)
|
||||
const bookmarked = hasSearch
|
||||
? []
|
||||
: filteredProjects.filter(
|
||||
(p) =>
|
||||
p.id !== currentId &&
|
||||
bookmarkedIds.has(p.id) &&
|
||||
!recentIds.has(p.id)
|
||||
);
|
||||
|
||||
// Exclude current, bookmarked, and recent from "others" only when those
|
||||
// sections are visible. Search mode surfaces every matching project here.
|
||||
const bookmarkedAndRecentIds = new Set([
|
||||
...bookmarked.map((p) => p.id),
|
||||
...(hasSearch ? [] : recentIds),
|
||||
]);
|
||||
const others = filteredProjects.filter(
|
||||
(p) => p.id !== currentId && !recentIds.has(p.id)
|
||||
(p) => p.id !== currentId && !bookmarkedAndRecentIds.has(p.id)
|
||||
);
|
||||
|
||||
return {
|
||||
recent: searchQuery.trim() ? [] : recentProjects,
|
||||
bookmarked,
|
||||
recent: hasSearch ? [] : 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 +226,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,20 +262,46 @@ 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(
|
||||
(project: ProjectInfo) => {
|
||||
onSelect(project);
|
||||
onSelect?.(project);
|
||||
setIsOpen(false);
|
||||
setSearchQuery("");
|
||||
},
|
||||
@@ -207,11 +317,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 +339,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 +405,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 +461,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 +470,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 +574,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" : ""
|
||||
}`}
|
||||
|
||||
@@ -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", () => {
|
||||
@@ -175,6 +193,23 @@ describe("ProjectSelector", () => {
|
||||
expect(onSelect).toHaveBeenCalledWith(projectTwo);
|
||||
});
|
||||
|
||||
it("does not throw when clicking a project without onSelect", () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Project One" }),
|
||||
makeProject({ id: "proj_2", name: "Project Two" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
expect(() => fireEvent.click(screen.getByText("Project Two"))).not.toThrow();
|
||||
expect(screen.queryByTestId("project-selector-dropdown")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes dropdown after selection", () => {
|
||||
const onSelect = vi.fn();
|
||||
|
||||
@@ -231,14 +266,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 +280,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 +404,468 @@ 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("shows bookmarked projects that match an active search", () => {
|
||||
mockBookmarkedIds = new Set(["proj_2"]);
|
||||
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Alpha" }),
|
||||
makeProject({ id: "proj_2", name: "Starred Project" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onSelect={noop}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
fireEvent.change(screen.getByPlaceholderText("Search projects..."), {
|
||||
target: { value: "Starred" },
|
||||
});
|
||||
|
||||
expect(screen.getByText("Starred")).toBeDefined();
|
||||
expect(screen.getByText("Project")).toBeDefined();
|
||||
expect(screen.queryByTestId("project-selector-no-results")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not throw when pressing Enter on a highlighted project without onSelect", async () => {
|
||||
render(
|
||||
<ProjectSelector
|
||||
projects={[
|
||||
makeProject({ id: "proj_1", name: "Alpha" }),
|
||||
makeProject({ id: "proj_2", name: "Beta" }),
|
||||
]}
|
||||
currentProject={makeProject({ id: "proj_1" })}
|
||||
onViewAll={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("project-selector-trigger"));
|
||||
const searchInput = screen.getByPlaceholderText("Search projects...");
|
||||
fireEvent.change(searchInput, { target: { value: "Beta" } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("project-selector-item-proj_2").className).toContain("highlighted");
|
||||
});
|
||||
expect(() => fireEvent.keyDown(searchInput, { key: "Enter" })).not.toThrow();
|
||||
expect(screen.queryByTestId("project-selector-dropdown")).toBeNull();
|
||||
});
|
||||
|
||||
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("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
52
packages/dashboard/app/hooks/useProjectBookmarks.ts
Normal file
52
packages/dashboard/app/hooks/useProjectBookmarks.ts
Normal 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 };
|
||||
}
|
||||
658
packages/engine/src/__tests__/retry-with-backoff.test.ts
Normal file
658
packages/engine/src/__tests__/retry-with-backoff.test.ts
Normal file
@@ -0,0 +1,658 @@
|
||||
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("does not let a custom retry check override RateLimitError instances", async () => {
|
||||
const fn = vi.fn().mockRejectedValue(new RateLimitError("429"));
|
||||
const onRetry = vi.fn();
|
||||
|
||||
await expect(
|
||||
withRetry(fn, {
|
||||
baseDelayMs: 100,
|
||||
onRetry,
|
||||
isRetryable: () => true,
|
||||
}),
|
||||
).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");
|
||||
});
|
||||
|
||||
it("passes a per-attempt abort signal that is aborted on timeout", async () => {
|
||||
let attemptSignal: AbortSignal | undefined;
|
||||
const fn = vi.fn((signal?: AbortSignal) => {
|
||||
attemptSignal = signal;
|
||||
return new Promise(() => {});
|
||||
});
|
||||
|
||||
const promise = withRetry(fn, {
|
||||
maxRetries: 0,
|
||||
timeoutMs: 500,
|
||||
});
|
||||
const assertion = expect(promise).rejects.toBeInstanceOf(TimeoutError);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
|
||||
await assertion;
|
||||
expect(attemptSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(attemptSignal?.aborted).toBe(true);
|
||||
expect(attemptSignal?.reason).toBeInstanceOf(TimeoutError);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
269
packages/engine/src/engine-errors.ts
Normal file
269
packages/engine/src/engine-errors.ts
Normal 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);
|
||||
}
|
||||
344
packages/engine/src/retry-with-backoff.ts
Normal file
344
packages/engine/src/retry-with-backoff.ts
Normal 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,
|
||||
RateLimitError,
|
||||
TimeoutError,
|
||||
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: (signal?: AbortSignal) => 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;
|
||||
const timeoutErr = new TimeoutError(`Operation timed out after ${timeoutMs}ms`, timeoutMs);
|
||||
ac.abort(timeoutErr);
|
||||
reject(timeoutErr);
|
||||
}, timeoutMs);
|
||||
|
||||
fn(ac.signal)
|
||||
.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: (signal?: AbortSignal) => Promise<T>,
|
||||
options: RetryOptions = {},
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxRetries = 3,
|
||||
baseDelayMs = 1_000,
|
||||
maxDelayMs = 30_000,
|
||||
timeoutMs,
|
||||
jitter = "full",
|
||||
onRetry,
|
||||
signal,
|
||||
isRetryable: customIsRetryable,
|
||||
} = options;
|
||||
|
||||
let lastError: EngineError | undefined;
|
||||
|
||||
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(signal);
|
||||
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 (classified instanceof RateLimitError || 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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: (signal?: AbortSignal) => Promise<T>,
|
||||
options: RetryOptions = {},
|
||||
): Promise<RetryResult<T>> {
|
||||
const startTime = Date.now();
|
||||
let retries = 0;
|
||||
|
||||
const result = await withRetry<T>(async (signal) => {
|
||||
return fn(signal);
|
||||
}, {
|
||||
...options,
|
||||
onRetry: (attempt, delayMs, err) => {
|
||||
retries = attempt;
|
||||
options.onRetry?.(attempt, delayMs, err);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
value: result,
|
||||
retries,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user