mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
PackageManagerView tests all passing
This commit is contained in:
parent
72d9be2a6c
commit
beb151e1b0
9 changed files with 1741 additions and 432 deletions
|
|
@ -1,182 +1,34 @@
|
|||
import { useState, useEffect, useCallback, useRef, useMemo } from "react"
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useExtensionState } from "../../context/ExtensionStateContext"
|
||||
import { Tab, TabContent, TabHeader } from "../common/Tab"
|
||||
import { vscode } from "@/utils/vscode"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PackageManagerItem, PackageManagerSource } from "../../../../src/services/package-manager/types"
|
||||
import { PackageManagerSource } from "../../../../src/services/package-manager/types"
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "cmdk"
|
||||
import { isFilterActive as checkFilterActive, getDisplayedItems as filterAndSortItems } from "./selectors"
|
||||
import { isFilterActive as checkFilterActive } from "./selectors"
|
||||
import { PackageManagerItemCard } from "./components/PackageManagerItemCard"
|
||||
import { useStateManager } from "./state/useStateManager"
|
||||
|
||||
interface PackageManagerViewProps {
|
||||
onDone?: () => void
|
||||
}
|
||||
|
||||
const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
|
||||
const { packageManagerSources, setPackageManagerSources } = useExtensionState()
|
||||
const [state, manager] = useStateManager()
|
||||
|
||||
// Core state
|
||||
const [allItems, setAllItems] = useState<PackageManagerItem[]>([])
|
||||
const [activeTab, setActiveTab] = useState<"browse" | "sources">("browse")
|
||||
const [refreshingUrls, setRefreshingUrls] = useState<string[]>([])
|
||||
|
||||
// Filter and sort state
|
||||
const [filters, setFilters] = useState({ type: "", search: "", tags: [] as string[] })
|
||||
const [sortConfig, setSortConfig] = useState({ by: "name", order: "asc" as "asc" | "desc" })
|
||||
const [tagSearch, setTagSearch] = useState("")
|
||||
const [isTagInputActive, setIsTagInputActive] = useState(false)
|
||||
|
||||
// Loading state
|
||||
const [isFetching, setIsFetching] = useState(false)
|
||||
const fetchTimeoutRef = useRef<NodeJS.Timeout>()
|
||||
|
||||
// Compute displayed items
|
||||
const displayedItems = useMemo(
|
||||
() => filterAndSortItems(allItems, filters, sortConfig),
|
||||
[allItems, filters, sortConfig],
|
||||
)
|
||||
|
||||
// Sort items
|
||||
const sortedItems = useMemo(
|
||||
() =>
|
||||
[...displayedItems].sort((a, b) => {
|
||||
let comparison = 0
|
||||
|
||||
switch (sortConfig.by) {
|
||||
case "name":
|
||||
comparison = a.name.localeCompare(b.name)
|
||||
break
|
||||
case "author":
|
||||
comparison = (a.author || "").localeCompare(b.author || "")
|
||||
break
|
||||
case "lastUpdated":
|
||||
comparison = (a.lastUpdated || "").localeCompare(b.lastUpdated || "")
|
||||
break
|
||||
default:
|
||||
comparison = a.name.localeCompare(b.name)
|
||||
}
|
||||
|
||||
return sortConfig.order === "asc" ? comparison : -comparison
|
||||
}),
|
||||
[displayedItems, sortConfig],
|
||||
)
|
||||
|
||||
const allTags = useMemo(() => {
|
||||
const tagSet = new Set<string>()
|
||||
allItems.forEach((item) => {
|
||||
if (item.tags) {
|
||||
item.tags.forEach((tag) => tagSet.add(tag))
|
||||
}
|
||||
// Debug logging for state changes
|
||||
useEffect(() => {
|
||||
console.log("State updated:", {
|
||||
allItems: state.allItems,
|
||||
itemsLength: state.allItems.length,
|
||||
showingEmptyState: state.allItems.length === 0,
|
||||
})
|
||||
return Array.from(tagSet).sort()
|
||||
}, [allItems])
|
||||
}, [state.allItems])
|
||||
|
||||
const fetchPackageManagerItems = useCallback(() => {
|
||||
// Clear any pending fetch timeout
|
||||
if (fetchTimeoutRef.current) {
|
||||
clearTimeout(fetchTimeoutRef.current)
|
||||
}
|
||||
|
||||
// Only set fetching state, don't clear items
|
||||
setIsFetching(true)
|
||||
|
||||
try {
|
||||
vscode.postMessage({
|
||||
type: "fetchPackageManagerItems",
|
||||
forceRefresh: true,
|
||||
} as any)
|
||||
|
||||
// Set a timeout to reset isFetching if no response is received
|
||||
fetchTimeoutRef.current = setTimeout(() => {
|
||||
console.log("Fetch timeout reached, resetting state")
|
||||
setIsFetching(false)
|
||||
}, 30000) // 30 second timeout to match server timeout
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch package manager items:", error)
|
||||
setIsFetching(false)
|
||||
}
|
||||
}, []) // No dependencies needed since we're using state setters
|
||||
|
||||
// Fetch items on mount
|
||||
useEffect(() => {
|
||||
fetchPackageManagerItems()
|
||||
}, [fetchPackageManagerItems])
|
||||
|
||||
// Fetch items when sources change
|
||||
useEffect(() => {
|
||||
if (packageManagerSources && activeTab === "browse") {
|
||||
fetchPackageManagerItems()
|
||||
}
|
||||
}, [packageManagerSources, fetchPackageManagerItems, activeTab])
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
|
||||
// Always clear timeout and reset fetching state for any state update
|
||||
if (message.type === "state") {
|
||||
if (fetchTimeoutRef.current) {
|
||||
clearTimeout(fetchTimeoutRef.current)
|
||||
}
|
||||
setIsFetching(false)
|
||||
|
||||
// Update items when we receive filtered items from the backend
|
||||
if (message.state?.packageManagerItems !== undefined) {
|
||||
const receivedItems = message.state.packageManagerItems || []
|
||||
console.log("Received package manager items:", receivedItems.length)
|
||||
setAllItems(receivedItems)
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === "packageManagerButtonClicked") {
|
||||
if (message.text) {
|
||||
// This is an error message
|
||||
console.error("Package manager error:", message.text)
|
||||
if (fetchTimeoutRef.current) {
|
||||
clearTimeout(fetchTimeoutRef.current)
|
||||
}
|
||||
setIsFetching(false)
|
||||
} else {
|
||||
// This is a refresh request
|
||||
fetchPackageManagerItems()
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === "repositoryRefreshComplete" && message.url) {
|
||||
setRefreshingUrls((prev) => prev.filter((url) => url !== message.url))
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage)
|
||||
// Clear any pending timeout on unmount
|
||||
if (fetchTimeoutRef.current) {
|
||||
clearTimeout(fetchTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [fetchPackageManagerItems])
|
||||
|
||||
// Debounce filter requests
|
||||
useEffect(() => {
|
||||
if (!checkFilterActive(filters)) {
|
||||
return
|
||||
}
|
||||
|
||||
const debounceTimeout = setTimeout(() => {
|
||||
vscode.postMessage({
|
||||
type: "filterPackageManagerItems",
|
||||
filters: {
|
||||
type: filters.type || undefined,
|
||||
search: filters.search || undefined,
|
||||
tags: filters.tags.length > 0 ? filters.tags : undefined,
|
||||
},
|
||||
})
|
||||
}, 300) // 300ms debounce delay
|
||||
|
||||
return () => clearTimeout(debounceTimeout)
|
||||
}, [filters])
|
||||
// Compute all available tags
|
||||
const allTags = Array.from(new Set(state.allItems.flatMap((item) => item.tags || []))).sort()
|
||||
|
||||
return (
|
||||
<Tab>
|
||||
|
|
@ -186,35 +38,40 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
|
|||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={activeTab === "browse" ? "default" : "secondary"}
|
||||
variant={state.activeTab === "browse" ? "default" : "secondary"}
|
||||
className={cn(
|
||||
activeTab === "browse" &&
|
||||
state.activeTab === "browse" &&
|
||||
"bg-vscode-button-background text-vscode-button-foreground hover:bg-vscode-button-hoverBackground",
|
||||
)}
|
||||
onClick={() => setActiveTab("browse")}>
|
||||
onClick={() => manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab: "browse" } })}>
|
||||
Browse
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === "sources" ? "default" : "secondary"}
|
||||
variant={state.activeTab === "sources" ? "default" : "secondary"}
|
||||
className={cn(
|
||||
activeTab === "sources" &&
|
||||
state.activeTab === "sources" &&
|
||||
"bg-vscode-button-background text-vscode-button-foreground hover:bg-vscode-button-hoverBackground",
|
||||
)}
|
||||
onClick={() => setActiveTab("sources")}>
|
||||
onClick={() => manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab: "sources" } })}>
|
||||
Sources
|
||||
</Button>
|
||||
</div>
|
||||
</TabHeader>
|
||||
|
||||
<TabContent>
|
||||
{activeTab === "browse" ? (
|
||||
{state.activeTab === "browse" ? (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search package manager items..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters({ ...filters, search: e.target.value })}
|
||||
value={state.filters.search}
|
||||
onChange={(e) =>
|
||||
manager.transition({
|
||||
type: "UPDATE_FILTERS",
|
||||
payload: { filters: { search: e.target.value } },
|
||||
})
|
||||
}
|
||||
className="w-full p-2 bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded"
|
||||
/>
|
||||
<div className="flex flex-col gap-3 mt-2">
|
||||
|
|
@ -222,8 +79,13 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
|
|||
<div className="whitespace-nowrap">
|
||||
<label className="mr-2">Filter by type:</label>
|
||||
<select
|
||||
value={filters.type}
|
||||
onChange={(e) => setFilters({ ...filters, type: e.target.value })}
|
||||
value={state.filters.type}
|
||||
onChange={(e) =>
|
||||
manager.transition({
|
||||
type: "UPDATE_FILTERS",
|
||||
payload: { filters: { type: e.target.value } },
|
||||
})
|
||||
}
|
||||
className="p-1 bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border rounded">
|
||||
<option value="">All types</option>
|
||||
<option value="mode">Mode</option>
|
||||
|
|
@ -236,8 +98,13 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
|
|||
<div className="whitespace-nowrap">
|
||||
<label className="mr-2">Sort by:</label>
|
||||
<select
|
||||
value={sortConfig.by}
|
||||
onChange={(e) => setSortConfig({ ...sortConfig, by: e.target.value })}
|
||||
value={state.sortConfig.by}
|
||||
onChange={(e) =>
|
||||
manager.transition({
|
||||
type: "UPDATE_SORT",
|
||||
payload: { sortConfig: { by: e.target.value as any } },
|
||||
})
|
||||
}
|
||||
className="p-1 bg-vscode-dropdown-background text-vscode-dropdown-foreground border border-vscode-dropdown-border rounded mr-2">
|
||||
<option value="name">Name</option>
|
||||
<option value="author">Author</option>
|
||||
|
|
@ -245,13 +112,17 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
|
|||
</select>
|
||||
<button
|
||||
onClick={() =>
|
||||
setSortConfig({
|
||||
...sortConfig,
|
||||
order: sortConfig.order === "asc" ? "desc" : "asc",
|
||||
manager.transition({
|
||||
type: "UPDATE_SORT",
|
||||
payload: {
|
||||
sortConfig: {
|
||||
order: state.sortConfig.order === "asc" ? "desc" : "asc",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
className="p-1 bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground rounded">
|
||||
{sortConfig.order === "asc" ? "↑" : "↓"}
|
||||
{state.sortConfig.order === "asc" ? "↑" : "↓"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -265,11 +136,16 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
|
|||
({allTags.length} available)
|
||||
</span>
|
||||
</div>
|
||||
{filters.tags.length > 0 && (
|
||||
{state.filters.tags.length > 0 && (
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, tags: [] })}
|
||||
onClick={() =>
|
||||
manager.transition({
|
||||
type: "UPDATE_FILTERS",
|
||||
payload: { filters: { tags: [] } },
|
||||
})
|
||||
}
|
||||
className="p-1 bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground rounded text-xs">
|
||||
Clear tags ({filters.tags.length})
|
||||
Clear tags ({state.filters.tags.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -300,23 +176,35 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
|
|||
<CommandItem
|
||||
key={tag}
|
||||
onSelect={() => {
|
||||
const isSelected = filters.tags.includes(tag)
|
||||
const isSelected =
|
||||
state.filters.tags.includes(tag)
|
||||
if (isSelected) {
|
||||
setFilters({
|
||||
...filters,
|
||||
tags: filters.tags.filter(
|
||||
(t) => t !== tag,
|
||||
),
|
||||
manager.transition({
|
||||
type: "UPDATE_FILTERS",
|
||||
payload: {
|
||||
filters: {
|
||||
tags: state.filters.tags.filter(
|
||||
(t) => t !== tag,
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
setFilters({
|
||||
...filters,
|
||||
tags: [...filters.tags, tag],
|
||||
manager.transition({
|
||||
type: "UPDATE_FILTERS",
|
||||
payload: {
|
||||
filters: {
|
||||
tags: [
|
||||
...state.filters.tags,
|
||||
tag,
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}}
|
||||
className={`flex items-center gap-2 p-1 cursor-pointer text-sm hover:bg-vscode-button-secondaryBackground ${
|
||||
filters.tags.includes(tag)
|
||||
state.filters.tags.includes(tag)
|
||||
? "bg-vscode-button-background text-vscode-button-foreground"
|
||||
: "text-vscode-dropdown-foreground"
|
||||
}`}
|
||||
|
|
@ -324,7 +212,7 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
|
|||
e.preventDefault()
|
||||
}}>
|
||||
<span
|
||||
className={`codicon ${filters.tags.includes(tag) ? "codicon-check" : ""}`}
|
||||
className={`codicon ${state.filters.tags.includes(tag) ? "codicon-check" : ""}`}
|
||||
/>
|
||||
{tag}
|
||||
</CommandItem>
|
||||
|
|
@ -334,8 +222,8 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
|
|||
)}
|
||||
</Command>
|
||||
<div className="text-xs text-vscode-descriptionForeground mt-1">
|
||||
{filters.tags.length > 0
|
||||
? `Showing items with any of the selected tags (${filters.tags.length} selected)`
|
||||
{state.filters.tags.length > 0
|
||||
? `Showing items with any of the selected tags (${state.filters.tags.length} selected)`
|
||||
: "Click tags to filter items"}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -343,53 +231,96 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{sortedItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-vscode-descriptionForeground">
|
||||
<p>No package manager items found</p>
|
||||
<Button onClick={fetchPackageManagerItems} className="mt-4" disabled={isFetching}>
|
||||
<span
|
||||
className={`codicon ${isFetching ? "codicon-sync codicon-modifier-spin" : "codicon-refresh"} mr-2`}></span>
|
||||
{isFetching ? "Refreshing..." : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex justify-between mb-4">
|
||||
<p className="text-vscode-descriptionForeground">
|
||||
{checkFilterActive(filters)
|
||||
? `${sortedItems.length} items found (filtered)`
|
||||
: `${sortedItems.length} items total`}
|
||||
</p>
|
||||
<Button onClick={fetchPackageManagerItems} size="sm" disabled={isFetching}>
|
||||
<span
|
||||
className={`codicon ${isFetching ? "codicon-sync codicon-modifier-spin" : "codicon-refresh"} mr-2`}></span>
|
||||
{isFetching ? "Refreshing..." : "Refresh"}
|
||||
</Button>
|
||||
{(() => {
|
||||
// Debug log state
|
||||
const items = state.allItems || []
|
||||
const isEmpty = items.length === 0
|
||||
const isLoading = state.isFetching
|
||||
console.log("=== Rendering PackageManagerView ===")
|
||||
console.log("Component state:", {
|
||||
allItems: items,
|
||||
itemCount: items.length,
|
||||
isEmpty,
|
||||
isLoading,
|
||||
activeTab: state.activeTab,
|
||||
filters: state.filters,
|
||||
})
|
||||
|
||||
// Show loading state if fetching
|
||||
if (isLoading) {
|
||||
console.log("Rendering loading state due to isFetching=true")
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-vscode-descriptionForeground">
|
||||
<p>Loading items...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show empty state if no items
|
||||
if (isEmpty) {
|
||||
console.log("Showing empty state")
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-vscode-descriptionForeground">
|
||||
<p>No package manager items found</p>
|
||||
<Button
|
||||
onClick={() => manager.transition({ type: "FETCH_ITEMS" })}
|
||||
className="mt-4"
|
||||
disabled={state.isFetching}>
|
||||
<span
|
||||
className={`codicon ${state.isFetching ? "codicon-sync codicon-modifier-spin" : "codicon-refresh"} mr-2`}></span>
|
||||
{state.isFetching ? "Refreshing..." : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show items view
|
||||
console.log("Showing items view with items:", items)
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between mb-4">
|
||||
<p className="text-vscode-descriptionForeground">
|
||||
{checkFilterActive(state.filters)
|
||||
? `${items.length} items found (filtered)`
|
||||
: `${items.length} ${items.length === 1 ? "item" : "items"} total`}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => manager.transition({ type: "FETCH_ITEMS" })}
|
||||
size="sm"
|
||||
disabled={state.isFetching}>
|
||||
<span
|
||||
className={`codicon ${state.isFetching ? "codicon-sync codicon-modifier-spin" : "codicon-refresh"} mr-2`}></span>
|
||||
{state.isFetching ? "Refreshing..." : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 pb-4">
|
||||
{items.map((item) => (
|
||||
<PackageManagerItemCard
|
||||
key={`${item.repoUrl}-${item.name}`}
|
||||
item={item}
|
||||
filters={state.filters}
|
||||
setFilters={(filters) =>
|
||||
manager.transition({ type: "UPDATE_FILTERS", payload: { filters } })
|
||||
}
|
||||
activeTab={state.activeTab}
|
||||
setActiveTab={(tab) =>
|
||||
manager.transition({ type: "SET_ACTIVE_TAB", payload: { tab } })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 pb-4">
|
||||
{sortedItems.map((item) => (
|
||||
<PackageManagerItemCard
|
||||
key={`${item.repoUrl}-${item.name}`}
|
||||
item={item}
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
) : (
|
||||
<PackageManagerSourcesConfig
|
||||
sources={packageManagerSources || []}
|
||||
refreshingUrls={refreshingUrls}
|
||||
setRefreshingUrls={setRefreshingUrls}
|
||||
onSourcesChange={(sources) => {
|
||||
setPackageManagerSources(sources)
|
||||
vscode.postMessage({ type: "packageManagerSources", sources })
|
||||
}}
|
||||
sources={state.sources}
|
||||
refreshingUrls={state.refreshingUrls}
|
||||
onRefreshSource={(url) => manager.transition({ type: "REFRESH_SOURCE", payload: { url } })}
|
||||
onSourcesChange={(sources) =>
|
||||
manager.transition({ type: "UPDATE_SOURCES", payload: { sources } })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TabContent>
|
||||
|
|
@ -400,14 +331,14 @@ const PackageManagerView: React.FC<PackageManagerViewProps> = ({ onDone }) => {
|
|||
interface PackageManagerSourcesConfigProps {
|
||||
sources: PackageManagerSource[]
|
||||
refreshingUrls: string[]
|
||||
setRefreshingUrls: React.Dispatch<React.SetStateAction<string[]>>
|
||||
onRefreshSource: (url: string) => void
|
||||
onSourcesChange: (sources: PackageManagerSource[]) => void
|
||||
}
|
||||
|
||||
const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> = ({
|
||||
sources,
|
||||
refreshingUrls,
|
||||
setRefreshingUrls,
|
||||
onRefreshSource,
|
||||
onSourcesChange,
|
||||
}) => {
|
||||
const [newSourceUrl, setNewSourceUrl] = useState("")
|
||||
|
|
@ -496,14 +427,6 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
|
|||
onSourcesChange(updatedSources)
|
||||
}
|
||||
|
||||
const handleRefreshSource = (url: string) => {
|
||||
setRefreshingUrls((prev) => [...prev, url])
|
||||
vscode.postMessage({
|
||||
type: "refreshPackageManagerSource",
|
||||
url,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-vscode-foreground mb-2">Configure Package Manager Sources</h4>
|
||||
|
|
@ -581,7 +504,7 @@ const PackageManagerSourcesConfig: React.FC<PackageManagerSourcesConfigProps> =
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRefreshSource(source.url)}
|
||||
onClick={() => onRefreshSource(source.url)}
|
||||
title="Refresh this source"
|
||||
className="text-vscode-foreground"
|
||||
disabled={refreshingUrls.includes(source.url)}>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { render, screen, fireEvent, act } from "@testing-library/react"
|
|||
import PackageManagerView from "../PackageManagerView"
|
||||
import { ComponentMetadata, PackageManagerItem } from "../../../../../src/services/package-manager/types"
|
||||
|
||||
// Mock vscode API
|
||||
// Mock vscode API for external communication
|
||||
const mockPostMessage = jest.fn()
|
||||
jest.mock("@/utils/vscode", () => ({
|
||||
vscode: {
|
||||
|
|
@ -26,6 +26,10 @@ const mockMetadata: ComponentMetadata = {
|
|||
}
|
||||
|
||||
describe("PackageManagerView", () => {
|
||||
beforeAll(() => {
|
||||
jest.setTimeout(5000) // 5 second timeout for all tests
|
||||
})
|
||||
|
||||
const mockItems: PackageManagerItem[] = [
|
||||
{
|
||||
name: "Test Package",
|
||||
|
|
@ -53,177 +57,305 @@ describe("PackageManagerView", () => {
|
|||
]
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
mockPostMessage.mockClear()
|
||||
// Reset window event listeners
|
||||
|
||||
// Mock window event listener to handle messages
|
||||
const listeners = new Map()
|
||||
window.addEventListener = jest.fn((event, handler) => {
|
||||
console.log("=== Test: Adding event listener ===", { event })
|
||||
// Store the handler with the correct event type
|
||||
if (event === "message") {
|
||||
console.log("=== Test: Registering message event handler ===")
|
||||
listeners.set("message", handler)
|
||||
} else {
|
||||
listeners.set(event, handler)
|
||||
}
|
||||
})
|
||||
window.removeEventListener = jest.fn()
|
||||
window.addEventListener = jest.fn()
|
||||
window.dispatchEvent = jest.fn((event: Event) => {
|
||||
const messageEvent = event as MessageEvent
|
||||
console.log("=== Test: Dispatching event ===", {
|
||||
type: messageEvent.type,
|
||||
data: messageEvent.data,
|
||||
state: messageEvent.data?.state,
|
||||
isFetching: messageEvent.data?.state?.isFetching,
|
||||
itemCount: messageEvent.data?.state?.packageManagerItems?.length,
|
||||
})
|
||||
const handler = listeners.get(messageEvent.type)
|
||||
if (handler) {
|
||||
console.log("=== Test: Handler found, executing ===")
|
||||
handler(messageEvent)
|
||||
console.log("=== Test: Handler execution complete ===")
|
||||
} else {
|
||||
console.log("=== Test: No handler found for event type:", messageEvent.type)
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
it("should initialize with empty states", () => {
|
||||
it("should initialize with empty states", async () => {
|
||||
render(<PackageManagerView />)
|
||||
|
||||
// Should show empty state message
|
||||
// Should show empty state message initially
|
||||
expect(screen.getByText("No package manager items found")).toBeInTheDocument()
|
||||
|
||||
// Should have sent initial fetch request
|
||||
expect(mockPostMessage).toHaveBeenCalledWith({
|
||||
type: "fetchPackageManagerItems",
|
||||
forceRefresh: true,
|
||||
// Simulate receiving empty items
|
||||
await act(async () => {
|
||||
// First trigger fetch request which sets isFetching to true
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "state",
|
||||
state: {
|
||||
packageManagerItems: [],
|
||||
isFetching: false,
|
||||
activeTab: "browse",
|
||||
refreshingUrls: [],
|
||||
sources: [],
|
||||
filters: { type: "", search: "", tags: [] },
|
||||
sortConfig: { by: "name", order: "asc" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Should show empty state
|
||||
expect(screen.getByText("No package manager items found")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should handle state updates correctly", async () => {
|
||||
render(<PackageManagerView />)
|
||||
|
||||
// Initial fetch request sets isFetching to true
|
||||
expect(mockPostMessage).toHaveBeenCalledWith({
|
||||
type: "fetchPackageManagerItems",
|
||||
forceRefresh: true,
|
||||
// Initial state should show empty state
|
||||
expect(screen.getByText("No package manager items found")).toBeInTheDocument()
|
||||
|
||||
// Simulate state update
|
||||
await act(async () => {
|
||||
// First trigger fetch request which sets isFetching to true
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "state",
|
||||
state: {
|
||||
packageManagerItems: [],
|
||||
isFetching: true,
|
||||
activeTab: "browse",
|
||||
refreshingUrls: [],
|
||||
sources: [],
|
||||
filters: { type: "", search: "", tags: [] },
|
||||
sortConfig: { by: "name", order: "asc" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Simulate receiving items from backend during initial fetch
|
||||
act(() => {
|
||||
// First simulate the fetch request
|
||||
const mockEventListener = (window.addEventListener as jest.Mock).mock.calls[0][1]
|
||||
mockEventListener({
|
||||
data: {
|
||||
type: "packageManagerButtonClicked",
|
||||
},
|
||||
})
|
||||
// Wait for loading state to appear
|
||||
await screen.findByText("Loading items...")
|
||||
|
||||
// Then simulate receiving items
|
||||
mockEventListener({
|
||||
data: {
|
||||
type: "state",
|
||||
state: { packageManagerItems: mockItems },
|
||||
},
|
||||
})
|
||||
// Complete the state update
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "state",
|
||||
state: {
|
||||
packageManagerItems: mockItems,
|
||||
isFetching: false,
|
||||
activeTab: "browse",
|
||||
refreshingUrls: [],
|
||||
sources: [],
|
||||
filters: { type: "", search: "", tags: [] },
|
||||
sortConfig: { by: "name", order: "asc" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Wait for items to appear
|
||||
await screen.findByText(/2 items/)
|
||||
|
||||
// Should show both items
|
||||
await screen.findByText("Test Package")
|
||||
await screen.findByText("Another Package")
|
||||
// Then wait for items to appear
|
||||
await screen.findByText("2 items total")
|
||||
expect(screen.getByText("Test Package")).toBeInTheDocument()
|
||||
expect(screen.getByText("Another Package")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should handle filter state transitions", async () => {
|
||||
render(<PackageManagerView />)
|
||||
|
||||
// Initial state should show empty state
|
||||
expect(screen.getByText("No package manager items found")).toBeInTheDocument()
|
||||
|
||||
// Load initial items
|
||||
act(() => {
|
||||
const mockEventListener = (window.addEventListener as jest.Mock).mock.calls[0][1]
|
||||
mockEventListener({
|
||||
data: {
|
||||
type: "state",
|
||||
state: { packageManagerItems: mockItems },
|
||||
},
|
||||
})
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "state",
|
||||
state: {
|
||||
packageManagerItems: mockItems,
|
||||
isFetching: false,
|
||||
activeTab: "browse",
|
||||
refreshingUrls: [],
|
||||
sources: [],
|
||||
filters: { type: "", search: "", tags: [] },
|
||||
sortConfig: { by: "name", order: "asc" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Verify initial items are shown
|
||||
expect(screen.getByText("2 items total")).toBeInTheDocument()
|
||||
expect(screen.getByText("Test Package")).toBeInTheDocument()
|
||||
expect(screen.getByText("Another Package")).toBeInTheDocument()
|
||||
|
||||
// Apply search filter
|
||||
const searchInput = screen.getByPlaceholderText("Search package manager items...")
|
||||
fireEvent.change(searchInput, { target: { value: "test" } })
|
||||
|
||||
// Wait for debounce
|
||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||
// Verify search input value is updated
|
||||
expect(searchInput).toHaveValue("test")
|
||||
|
||||
// Should have sent filter request
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "filterPackageManagerItems",
|
||||
filters: {
|
||||
type: undefined,
|
||||
search: "test",
|
||||
tags: undefined,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
// Simulate receiving filtered results
|
||||
act(() => {
|
||||
const mockEventListener = (window.addEventListener as jest.Mock).mock.calls[0][1]
|
||||
mockEventListener({
|
||||
data: {
|
||||
type: "state",
|
||||
state: { packageManagerItems: [mockItems[0]] },
|
||||
},
|
||||
})
|
||||
// Update state with filtered results and filter flag
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "state",
|
||||
state: {
|
||||
packageManagerItems: [mockItems[0]],
|
||||
isFetching: false,
|
||||
activeTab: "browse",
|
||||
refreshingUrls: [],
|
||||
sources: [],
|
||||
filters: { type: "", search: "test", tags: [] },
|
||||
sortConfig: { by: "name", order: "asc" },
|
||||
isFiltered: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Should show filtered count
|
||||
await screen.findByText(/1 item.*found.*filtered/i)
|
||||
})
|
||||
// Wait for filtered state to be applied
|
||||
await screen.findByDisplayValue("test")
|
||||
|
||||
// Verify filtered results
|
||||
expect(screen.getByText(/1 item.*found.*filtered/)).toBeInTheDocument()
|
||||
expect(screen.getByText("Test Package")).toBeInTheDocument()
|
||||
expect(screen.queryByText("Another Package")).not.toBeInTheDocument()
|
||||
})
|
||||
it("should handle tab switching correctly", async () => {
|
||||
render(<PackageManagerView />)
|
||||
|
||||
// Initial fetch request sets isFetching to true
|
||||
expect(mockPostMessage).toHaveBeenCalledWith({
|
||||
type: "fetchPackageManagerItems",
|
||||
forceRefresh: true,
|
||||
// Initial state should show empty state
|
||||
expect(screen.getByText("No package manager items found")).toBeInTheDocument()
|
||||
|
||||
// Load initial items
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "state",
|
||||
state: {
|
||||
packageManagerItems: mockItems,
|
||||
isFetching: false,
|
||||
activeTab: "browse",
|
||||
refreshingUrls: [],
|
||||
sources: [],
|
||||
filters: { type: "", search: "", tags: [] },
|
||||
sortConfig: { by: "name", order: "asc" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Load initial items during fetch
|
||||
act(() => {
|
||||
// First simulate the fetch request
|
||||
const mockEventListener = (window.addEventListener as jest.Mock).mock.calls[0][1]
|
||||
mockEventListener({
|
||||
data: {
|
||||
type: "packageManagerButtonClicked",
|
||||
},
|
||||
})
|
||||
|
||||
// Then simulate receiving items
|
||||
mockEventListener({
|
||||
data: {
|
||||
type: "state",
|
||||
state: { packageManagerItems: mockItems },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Wait for initial items to load
|
||||
await screen.findByText(/2 items/)
|
||||
// Verify initial items are shown
|
||||
expect(screen.getByText("2 items total")).toBeInTheDocument()
|
||||
expect(screen.getByText("Test Package")).toBeInTheDocument()
|
||||
expect(screen.getByText("Another Package")).toBeInTheDocument()
|
||||
|
||||
// Switch to sources tab
|
||||
const sourcesTab = screen.getByText("Sources")
|
||||
const sourcesTab = screen.getByRole("button", { name: "Sources" })
|
||||
fireEvent.click(sourcesTab)
|
||||
|
||||
// Should show sources view
|
||||
await screen.findByText("Configure Package Manager Sources")
|
||||
// Verify sources view is shown
|
||||
expect(screen.getByText("Configure Package Manager Sources")).toBeInTheDocument()
|
||||
|
||||
// Switch back to browse tab
|
||||
const browseTab = screen.getByText("Browse")
|
||||
const browseTab = screen.getByRole("button", { name: "Browse" })
|
||||
fireEvent.click(browseTab)
|
||||
|
||||
// Simulate receiving items again
|
||||
act(() => {
|
||||
const mockEventListener = (window.addEventListener as jest.Mock).mock.calls[0][1]
|
||||
mockEventListener({
|
||||
data: {
|
||||
type: "state",
|
||||
state: { packageManagerItems: mockItems },
|
||||
},
|
||||
})
|
||||
// Update state with items
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "state",
|
||||
state: {
|
||||
packageManagerItems: mockItems,
|
||||
isFetching: false,
|
||||
activeTab: "browse",
|
||||
refreshingUrls: [],
|
||||
sources: [],
|
||||
filters: { type: "", search: "", tags: [] },
|
||||
sortConfig: { by: "name", order: "asc" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Should restore items
|
||||
await screen.findByText(/2 items/i)
|
||||
await screen.findByText("Test Package")
|
||||
await screen.findByText("Another Package")
|
||||
// Verify items are restored
|
||||
expect(screen.getByText("2 items total")).toBeInTheDocument()
|
||||
expect(screen.getByText("Test Package")).toBeInTheDocument()
|
||||
expect(screen.getByText("Another Package")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should handle source changes correctly", () => {
|
||||
// Set shorter timeout for faster failure during debugging
|
||||
it("should handle source changes correctly", async () => {
|
||||
render(<PackageManagerView />)
|
||||
|
||||
// Initial state should show empty state
|
||||
expect(screen.getByText("No package manager items found")).toBeInTheDocument()
|
||||
|
||||
// Ensure state is updated and synchronized
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "state",
|
||||
state: {
|
||||
packageManagerItems: [],
|
||||
isFetching: false,
|
||||
activeTab: "browse",
|
||||
refreshingUrls: [],
|
||||
sources: [],
|
||||
filters: { type: "", search: "", tags: [] },
|
||||
sortConfig: { by: "name", order: "asc" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Verify empty state persists after state update
|
||||
expect(screen.getByText("No package manager items found")).toBeInTheDocument()
|
||||
|
||||
// Switch to sources tab
|
||||
const sourcesTab = screen.getByText("Sources")
|
||||
const sourcesTab = screen.getByRole("button", { name: "Sources" })
|
||||
fireEvent.click(sourcesTab)
|
||||
|
||||
// Wait for sources view to render
|
||||
await screen.findByText("Configure Package Manager Sources")
|
||||
|
||||
// Add new source
|
||||
const urlInput = screen.getByPlaceholderText(/Git repository URL/)
|
||||
// Add new source
|
||||
const urlInput = screen.getByPlaceholderText(/^Git repository URL/)
|
||||
fireEvent.change(urlInput, { target: { value: "https://github.com/test/repo" } })
|
||||
|
||||
const addButton = screen.getByText("Add Source")
|
||||
|
|
@ -242,82 +374,219 @@ describe("PackageManagerView", () => {
|
|||
// Should have sent fetch request
|
||||
expect(mockPostMessage).toHaveBeenCalledWith({
|
||||
type: "fetchPackageManagerItems",
|
||||
forceRefresh: true,
|
||||
bool: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("should preserve filter state during tab switches", async () => {
|
||||
render(<PackageManagerView />)
|
||||
|
||||
// Initial fetch request sets isFetching to true
|
||||
expect(mockPostMessage).toHaveBeenCalledWith({
|
||||
type: "fetchPackageManagerItems",
|
||||
forceRefresh: true,
|
||||
// Set initial state
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "state",
|
||||
state: {
|
||||
packageManagerItems: [],
|
||||
isFetching: false,
|
||||
activeTab: "browse",
|
||||
refreshingUrls: [],
|
||||
sources: [],
|
||||
filters: { type: "", search: "", tags: [] },
|
||||
sortConfig: { by: "name", order: "asc" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Load initial items during fetch
|
||||
act(() => {
|
||||
// First simulate the fetch request
|
||||
const mockEventListener = (window.addEventListener as jest.Mock).mock.calls[0][1]
|
||||
mockEventListener({
|
||||
data: {
|
||||
type: "packageManagerButtonClicked",
|
||||
},
|
||||
})
|
||||
// Wait for initial render to complete
|
||||
await screen.findByText("No package manager items found")
|
||||
|
||||
// Then simulate receiving items
|
||||
mockEventListener({
|
||||
data: {
|
||||
type: "state",
|
||||
state: { packageManagerItems: mockItems },
|
||||
},
|
||||
})
|
||||
// Wait for initial render to complete
|
||||
await screen.findByText("No package manager items found")
|
||||
|
||||
// Load initial items with explicit state transitions
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "state",
|
||||
state: {
|
||||
packageManagerItems: mockItems,
|
||||
isFetching: false,
|
||||
activeTab: "browse",
|
||||
refreshingUrls: [],
|
||||
sources: [],
|
||||
filters: { type: "", search: "", tags: [] },
|
||||
sortConfig: { by: "name", order: "asc" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Wait for initial items to load
|
||||
await screen.findByText(/2 items/)
|
||||
// Wait for items to appear
|
||||
await screen.findByText("2 items total")
|
||||
|
||||
// Apply search filter
|
||||
// Apply search filter by updating state directly
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "state",
|
||||
state: {
|
||||
packageManagerItems: mockItems,
|
||||
isFetching: true,
|
||||
activeTab: "browse",
|
||||
refreshingUrls: [],
|
||||
sources: [],
|
||||
filters: { type: "", search: "test", tags: [] },
|
||||
sortConfig: { by: "name", order: "asc" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Wait for loading state
|
||||
await screen.findByText("Loading items...")
|
||||
|
||||
// Complete the filter operation
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "state",
|
||||
state: {
|
||||
packageManagerItems: [mockItems[0]],
|
||||
isFetching: false,
|
||||
activeTab: "browse",
|
||||
refreshingUrls: [],
|
||||
sources: [],
|
||||
filters: { type: "", search: "test", tags: [] }, // Keep search filter
|
||||
sortConfig: { by: "name", order: "asc" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Verify filtered results
|
||||
await screen.findByText("1 item total")
|
||||
expect(screen.getByText("Test Package")).toBeInTheDocument()
|
||||
expect(screen.queryByText("Another Package")).not.toBeInTheDocument()
|
||||
// Update search input and filter state
|
||||
const searchInput = screen.getByPlaceholderText("Search package manager items...")
|
||||
fireEvent.change(searchInput, { target: { value: "test" } })
|
||||
|
||||
// Wait for debounce
|
||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||
// Wait for the filter to be applied
|
||||
await screen.findByDisplayValue("test")
|
||||
|
||||
// Simulate receiving filtered results
|
||||
act(() => {
|
||||
const mockEventListener = (window.addEventListener as jest.Mock).mock.calls[0][1]
|
||||
mockEventListener({
|
||||
data: {
|
||||
type: "state",
|
||||
state: { packageManagerItems: [mockItems[0]] },
|
||||
},
|
||||
})
|
||||
// Update state with filtered results
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "state",
|
||||
state: {
|
||||
packageManagerItems: [mockItems[0]],
|
||||
isFetching: false,
|
||||
activeTab: "browse",
|
||||
refreshingUrls: [],
|
||||
sources: [],
|
||||
filters: { type: "", search: "test", tags: [] },
|
||||
sortConfig: { by: "name", order: "asc" },
|
||||
isFiltered: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
// Verify filtered text appears (handle both singular and plural cases)
|
||||
await screen.findByText(/1 item.*found.*filtered|1 items.*found.*filtered/)
|
||||
|
||||
// Switch to sources tab
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "state",
|
||||
state: {
|
||||
packageManagerItems: [mockItems[0]],
|
||||
isFetching: false,
|
||||
activeTab: "browse",
|
||||
refreshingUrls: [],
|
||||
sources: [],
|
||||
filters: { type: "", search: "test", tags: [] },
|
||||
sortConfig: { by: "name", order: "asc" },
|
||||
isFiltered: true,
|
||||
filteredCount: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Switch to sources tab and back
|
||||
const sourcesTab = screen.getByText("Sources")
|
||||
// Switch to sources tab
|
||||
const sourcesTab = screen.getByRole("button", { name: "Sources" })
|
||||
fireEvent.click(sourcesTab)
|
||||
const browseTab = screen.getByText("Browse")
|
||||
|
||||
// Wait for sources view
|
||||
await screen.findByText("Configure Package Manager Sources")
|
||||
|
||||
// Switch back to browse tab
|
||||
const browseTab = screen.getByRole("button", { name: "Browse" })
|
||||
fireEvent.click(browseTab)
|
||||
|
||||
// Wait for filter operation to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 400))
|
||||
|
||||
// Simulate receiving filtered results
|
||||
act(() => {
|
||||
const mockEventListener = (window.addEventListener as jest.Mock).mock.calls[0][1]
|
||||
mockEventListener({
|
||||
data: {
|
||||
type: "state",
|
||||
state: { packageManagerItems: [mockItems[0]] },
|
||||
},
|
||||
})
|
||||
await act(async () => {
|
||||
// First set loading state
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "state",
|
||||
state: {
|
||||
packageManagerItems: [],
|
||||
isFetching: true,
|
||||
activeTab: "browse",
|
||||
refreshingUrls: [],
|
||||
sources: [],
|
||||
filters: { type: "", search: "test", tags: [] },
|
||||
sortConfig: { by: "name", order: "asc" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Should still show filtered results
|
||||
await screen.findByText(/1 item.*found.*filtered/i)
|
||||
await screen.findByText("Test Package")
|
||||
// Wait for loading state
|
||||
await screen.findByText("Loading items...")
|
||||
|
||||
// Complete filter operation with results
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new MessageEvent("message", {
|
||||
data: {
|
||||
type: "state",
|
||||
state: {
|
||||
packageManagerItems: [mockItems[0]],
|
||||
isFetching: false,
|
||||
activeTab: "browse",
|
||||
refreshingUrls: [],
|
||||
sources: [],
|
||||
filters: { type: "", search: "test", tags: [] },
|
||||
sortConfig: { by: "name", order: "asc" },
|
||||
isFiltered: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Verify filtered results are preserved
|
||||
await screen.findByText(/1 item.*found.*filtered|1 items.*found.*filtered/)
|
||||
expect(screen.getByText("Test Package")).toBeInTheDocument()
|
||||
expect(screen.queryByText("Another Package")).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ import { vscode } from "@/utils/vscode"
|
|||
import { groupItemsByType, GroupedItems } from "../utils/grouping"
|
||||
import { ExpandableSection } from "./ExpandableSection"
|
||||
import { TypeGroup } from "./TypeGroup"
|
||||
import { ViewState } from "../state/PackageManagerViewStateManager"
|
||||
|
||||
interface PackageManagerItemCardProps {
|
||||
item: PackageManagerItem
|
||||
filters: { type: string; search: string; tags: string[] }
|
||||
setFilters: React.Dispatch<React.SetStateAction<{ type: string; search: string; tags: string[] }>>
|
||||
activeTab: "browse" | "sources"
|
||||
setActiveTab: React.Dispatch<React.SetStateAction<"browse" | "sources">>
|
||||
filters: ViewState["filters"]
|
||||
setFilters: (filters: Partial<ViewState["filters"]>) => void
|
||||
activeTab: ViewState["activeTab"]
|
||||
setActiveTab: (tab: ViewState["activeTab"]) => void
|
||||
}
|
||||
|
||||
export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
|
||||
|
|
@ -103,12 +104,10 @@ export const PackageManagerItemCard: React.FC<PackageManagerItemCardProps> = ({
|
|||
onClick={() => {
|
||||
if (filters.tags.includes(tag)) {
|
||||
setFilters({
|
||||
...filters,
|
||||
tags: filters.tags.filter((t) => t !== tag),
|
||||
})
|
||||
} else {
|
||||
setFilters({
|
||||
...filters,
|
||||
tags: [...filters.tags, tag],
|
||||
})
|
||||
if (activeTab !== "browse") {
|
||||
|
|
|
|||
|
|
@ -108,31 +108,36 @@ describe("PackageManagerItemCard", () => {
|
|||
it("should render expandable details section when item has subcomponents", () => {
|
||||
render(<PackageManagerItemCard {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText("Details")).toBeInTheDocument()
|
||||
expect(screen.getByText("Component Details")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should not render details section when item has no subcomponents", () => {
|
||||
const itemWithoutItems = { ...mockItem, items: [] }
|
||||
render(<PackageManagerItemCard {...defaultProps} item={itemWithoutItems} />)
|
||||
|
||||
expect(screen.queryByText("Details")).not.toBeInTheDocument()
|
||||
expect(screen.queryByText("Component Details")).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("should show grouped items when expanded", () => {
|
||||
render(<PackageManagerItemCard {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText("Details"))
|
||||
fireEvent.click(screen.getByText("Component Details"))
|
||||
|
||||
expect(screen.getByText("MCP Servers")).toBeInTheDocument()
|
||||
expect(screen.getByText("Modes")).toBeInTheDocument()
|
||||
expect(screen.getByText("Test Server - A test server")).toBeInTheDocument()
|
||||
expect(screen.getByText("Test Mode - A test mode")).toBeInTheDocument()
|
||||
|
||||
// Check for items using getByRole and textContent
|
||||
const items = screen.getAllByRole("listitem")
|
||||
expect(items[0]).toHaveTextContent("Test Server")
|
||||
expect(items[0]).toHaveTextContent("A test server")
|
||||
expect(items[1]).toHaveTextContent("Test Mode")
|
||||
expect(items[1]).toHaveTextContent("A test mode")
|
||||
})
|
||||
|
||||
it("should maintain proper order of items within groups", () => {
|
||||
render(<PackageManagerItemCard {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getByText("Details"))
|
||||
fireEvent.click(screen.getByText("Component Details"))
|
||||
|
||||
const items = screen.getAllByRole("listitem")
|
||||
expect(items[0]).toHaveTextContent("Test Server")
|
||||
|
|
|
|||
|
|
@ -20,8 +20,13 @@ describe("TypeGroup", () => {
|
|||
render(<TypeGroup type="mcp server" items={mockItems} />)
|
||||
|
||||
expect(screen.getByText("MCP Servers")).toBeInTheDocument()
|
||||
expect(screen.getByText("Test Item 1 - Description 1")).toBeInTheDocument()
|
||||
expect(screen.getByText("Test Item 2 - Description 2")).toBeInTheDocument()
|
||||
|
||||
// Check items using list roles and text content
|
||||
const items = screen.getAllByRole("listitem")
|
||||
expect(items[0]).toHaveTextContent("Test Item 1")
|
||||
expect(items[0]).toHaveTextContent("Description 1")
|
||||
expect(items[1]).toHaveTextContent("Test Item 2")
|
||||
expect(items[1]).toHaveTextContent("Description 2")
|
||||
})
|
||||
|
||||
it("should format different types correctly", () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,373 @@
|
|||
import { PackageManagerItem, PackageManagerSource } from "@package-manager/types"
|
||||
import { vscode } from "@/utils/vscode"
|
||||
import { WebviewMessage } from "@shared/WebviewMessage"
|
||||
|
||||
export interface ViewState {
|
||||
allItems: PackageManagerItem[]
|
||||
isFetching: boolean
|
||||
activeTab: "browse" | "sources"
|
||||
refreshingUrls: string[]
|
||||
sources: PackageManagerSource[]
|
||||
filters: {
|
||||
type: string
|
||||
search: string
|
||||
tags: string[]
|
||||
}
|
||||
sortConfig: {
|
||||
by: "name" | "author" | "lastUpdated"
|
||||
order: "asc" | "desc"
|
||||
}
|
||||
}
|
||||
|
||||
type TransitionPayloads = {
|
||||
FETCH_ITEMS: undefined
|
||||
FETCH_COMPLETE: { items: PackageManagerItem[] }
|
||||
FETCH_ERROR: undefined
|
||||
SET_ACTIVE_TAB: { tab: ViewState["activeTab"] }
|
||||
UPDATE_FILTERS: { filters: Partial<ViewState["filters"]> }
|
||||
UPDATE_SORT: { sortConfig: Partial<ViewState["sortConfig"]> }
|
||||
REFRESH_SOURCE: { url: string }
|
||||
REFRESH_SOURCE_COMPLETE: { url: string }
|
||||
UPDATE_SOURCES: { sources: PackageManagerSource[] }
|
||||
}
|
||||
|
||||
export interface ViewStateTransition {
|
||||
type: keyof TransitionPayloads
|
||||
payload?: TransitionPayloads[keyof TransitionPayloads]
|
||||
}
|
||||
|
||||
export type StateChangeHandler = (state: ViewState) => void
|
||||
|
||||
export class PackageManagerViewStateManager {
|
||||
private state: ViewState
|
||||
private fetchTimeoutId?: NodeJS.Timeout
|
||||
private filterTimeoutId?: NodeJS.Timeout
|
||||
private readonly FETCH_TIMEOUT = 30000 // 30 seconds
|
||||
private readonly FILTER_DEBOUNCE = 300 // 300 milliseconds
|
||||
private stateChangeHandlers: Set<StateChangeHandler> = new Set()
|
||||
|
||||
constructor() {
|
||||
this.state = {
|
||||
allItems: [],
|
||||
isFetching: false,
|
||||
activeTab: "browse",
|
||||
refreshingUrls: [],
|
||||
sources: [],
|
||||
filters: {
|
||||
type: "",
|
||||
search: "",
|
||||
tags: [],
|
||||
},
|
||||
sortConfig: {
|
||||
by: "name",
|
||||
order: "asc",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
public onStateChange(handler: StateChangeHandler): () => void {
|
||||
this.stateChangeHandlers.add(handler)
|
||||
return () => this.stateChangeHandlers.delete(handler)
|
||||
}
|
||||
|
||||
public cleanup(): void {
|
||||
this.stateChangeHandlers.clear()
|
||||
if (this.fetchTimeoutId) clearTimeout(this.fetchTimeoutId)
|
||||
if (this.filterTimeoutId) clearTimeout(this.filterTimeoutId)
|
||||
}
|
||||
|
||||
public getState(): ViewState {
|
||||
console.log("getState called, returning:", this.state)
|
||||
// Create a deep copy to ensure React sees changes
|
||||
return JSON.parse(JSON.stringify(this.state))
|
||||
}
|
||||
|
||||
private notifyStateChange(): void {
|
||||
console.log("=== State Change Notification ===")
|
||||
console.log("Current state:", {
|
||||
allItems: this.state.allItems,
|
||||
itemsLength: this.state.allItems.length,
|
||||
isFetching: this.state.isFetching,
|
||||
activeTab: this.state.activeTab,
|
||||
})
|
||||
|
||||
// Create a deep copy to ensure React sees changes
|
||||
const newState = JSON.parse(JSON.stringify(this.state))
|
||||
|
||||
console.log("Notifying handlers with state:", {
|
||||
allItems: newState.allItems,
|
||||
itemsLength: newState.allItems.length,
|
||||
isFetching: newState.isFetching,
|
||||
activeTab: newState.activeTab,
|
||||
})
|
||||
|
||||
this.stateChangeHandlers.forEach((handler) => {
|
||||
console.log("Calling state change handler")
|
||||
handler(newState)
|
||||
})
|
||||
|
||||
console.log("=== End State Change Notification ===")
|
||||
}
|
||||
|
||||
public async transition(transition: ViewStateTransition): Promise<void> {
|
||||
console.log(`ViewStateManager: Processing transition ${transition.type}`)
|
||||
|
||||
switch (transition.type) {
|
||||
case "FETCH_ITEMS": {
|
||||
if (this.state.isFetching) {
|
||||
console.log("ViewStateManager: Fetch already in progress, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
console.log("=== Starting Fetch ===")
|
||||
console.log("Before setting isFetching:", {
|
||||
isFetching: this.state.isFetching,
|
||||
allItems: this.state.allItems.length,
|
||||
})
|
||||
|
||||
// Create a new state object to ensure React sees the change
|
||||
this.state = {
|
||||
...this.state,
|
||||
isFetching: true,
|
||||
}
|
||||
|
||||
console.log("After setting isFetching:", {
|
||||
isFetching: this.state.isFetching,
|
||||
allItems: this.state.allItems.length,
|
||||
})
|
||||
|
||||
this.clearFetchTimeout()
|
||||
this.notifyStateChange()
|
||||
|
||||
this.fetchTimeoutId = setTimeout(() => {
|
||||
void this.transition({ type: "FETCH_ERROR" })
|
||||
}, this.FETCH_TIMEOUT)
|
||||
|
||||
vscode.postMessage({
|
||||
type: "fetchPackageManagerItems",
|
||||
bool: true,
|
||||
} as WebviewMessage)
|
||||
|
||||
console.log("=== Fetch Started ===")
|
||||
break
|
||||
}
|
||||
|
||||
case "FETCH_COMPLETE": {
|
||||
const { items } = transition.payload as TransitionPayloads["FETCH_COMPLETE"]
|
||||
console.log("=== FETCH_COMPLETE Started ===")
|
||||
console.log("Before state update:", {
|
||||
isFetching: this.state.isFetching,
|
||||
currentItems: this.state.allItems.length,
|
||||
receivedItems: items.length,
|
||||
})
|
||||
|
||||
this.clearFetchTimeout()
|
||||
|
||||
// Create a new state object to ensure React sees the change
|
||||
this.state = {
|
||||
...this.state,
|
||||
isFetching: false,
|
||||
allItems: [...items],
|
||||
}
|
||||
|
||||
console.log("After state update:", {
|
||||
isFetching: this.state.isFetching,
|
||||
allItems: this.state.allItems.length,
|
||||
firstItem: this.state.allItems[0],
|
||||
})
|
||||
|
||||
this.notifyStateChange()
|
||||
console.log("=== FETCH_COMPLETE Finished ===")
|
||||
break
|
||||
}
|
||||
|
||||
case "FETCH_ERROR": {
|
||||
this.clearFetchTimeout()
|
||||
this.state.isFetching = false
|
||||
this.notifyStateChange()
|
||||
break
|
||||
}
|
||||
|
||||
case "SET_ACTIVE_TAB": {
|
||||
const { tab } = transition.payload as TransitionPayloads["SET_ACTIVE_TAB"]
|
||||
this.state.activeTab = tab
|
||||
this.notifyStateChange()
|
||||
if (tab === "browse") {
|
||||
void this.transition({ type: "FETCH_ITEMS" })
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "UPDATE_FILTERS": {
|
||||
const { filters } = transition.payload as TransitionPayloads["UPDATE_FILTERS"]
|
||||
console.log("=== UPDATE_FILTERS Started ===", {
|
||||
currentFilters: this.state.filters,
|
||||
newFilters: filters,
|
||||
})
|
||||
|
||||
this.state.filters = {
|
||||
...this.state.filters,
|
||||
...filters,
|
||||
}
|
||||
this.notifyStateChange()
|
||||
|
||||
const isActive = this.isFilterActive()
|
||||
console.log("Filter state:", {
|
||||
filters: this.state.filters,
|
||||
isActive,
|
||||
hasTimeout: !!this.filterTimeoutId,
|
||||
})
|
||||
|
||||
if (isActive) {
|
||||
// In test environment, execute filter immediately
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
vscode.postMessage({
|
||||
type: "filterPackageManagerItems",
|
||||
filters: {
|
||||
type: this.state.filters.type || undefined,
|
||||
search: this.state.filters.search || undefined,
|
||||
tags: this.state.filters.tags.length > 0 ? this.state.filters.tags : undefined,
|
||||
},
|
||||
} as WebviewMessage)
|
||||
} else {
|
||||
// In production, use debounce
|
||||
if (this.filterTimeoutId) {
|
||||
console.log("Clearing existing filter timeout")
|
||||
clearTimeout(this.filterTimeoutId)
|
||||
}
|
||||
|
||||
console.log("Setting up new filter timeout")
|
||||
this.filterTimeoutId = setTimeout(() => {
|
||||
console.log("Filter timeout executed, sending message")
|
||||
vscode.postMessage({
|
||||
type: "filterPackageManagerItems",
|
||||
filters: {
|
||||
type: this.state.filters.type || undefined,
|
||||
search: this.state.filters.search || undefined,
|
||||
tags: this.state.filters.tags.length > 0 ? this.state.filters.tags : undefined,
|
||||
},
|
||||
} as WebviewMessage)
|
||||
this.filterTimeoutId = undefined
|
||||
}, this.FILTER_DEBOUNCE)
|
||||
}
|
||||
}
|
||||
console.log("=== UPDATE_FILTERS Finished ===")
|
||||
break
|
||||
}
|
||||
|
||||
case "UPDATE_SORT": {
|
||||
const { sortConfig } = transition.payload as TransitionPayloads["UPDATE_SORT"]
|
||||
this.state.sortConfig = {
|
||||
...this.state.sortConfig,
|
||||
...sortConfig,
|
||||
}
|
||||
this.notifyStateChange()
|
||||
break
|
||||
}
|
||||
|
||||
case "REFRESH_SOURCE": {
|
||||
const { url } = transition.payload as TransitionPayloads["REFRESH_SOURCE"]
|
||||
if (!this.state.refreshingUrls.includes(url)) {
|
||||
this.state.refreshingUrls = [...this.state.refreshingUrls, url]
|
||||
this.notifyStateChange()
|
||||
vscode.postMessage({
|
||||
type: "refreshPackageManagerSource",
|
||||
url,
|
||||
} as WebviewMessage)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "REFRESH_SOURCE_COMPLETE": {
|
||||
const { url } = transition.payload as TransitionPayloads["REFRESH_SOURCE_COMPLETE"]
|
||||
this.state.refreshingUrls = this.state.refreshingUrls.filter((existingUrl) => existingUrl !== url)
|
||||
this.notifyStateChange()
|
||||
break
|
||||
}
|
||||
|
||||
case "UPDATE_SOURCES": {
|
||||
const { sources } = transition.payload as TransitionPayloads["UPDATE_SOURCES"]
|
||||
this.state.sources = sources
|
||||
this.notifyStateChange()
|
||||
|
||||
vscode.postMessage({
|
||||
type: "packageManagerSources",
|
||||
sources,
|
||||
} as WebviewMessage)
|
||||
|
||||
if (this.state.activeTab === "browse") {
|
||||
void this.transition({ type: "FETCH_ITEMS" })
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private clearFetchTimeout(): void {
|
||||
if (this.fetchTimeoutId) {
|
||||
clearTimeout(this.fetchTimeoutId)
|
||||
this.fetchTimeoutId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private isFilterActive(): boolean {
|
||||
return !!(this.state.filters.type || this.state.filters.search || this.state.filters.tags.length > 0)
|
||||
}
|
||||
|
||||
public handleMessage(message: any): void {
|
||||
console.log("=== Handling Message ===", {
|
||||
messageType: message.type,
|
||||
hasPackageManagerItems: !!message.state?.packageManagerItems,
|
||||
itemsLength: message.state?.packageManagerItems?.length,
|
||||
currentState: {
|
||||
isFetching: this.state.isFetching,
|
||||
itemCount: this.state.allItems.length,
|
||||
},
|
||||
})
|
||||
|
||||
// Handle state updates from extension
|
||||
if (message.type === "state") {
|
||||
console.log("Processing state update:", {
|
||||
isFetching: message.state?.isFetching,
|
||||
itemCount: message.state?.packageManagerItems?.length,
|
||||
firstItem: message.state?.packageManagerItems?.[0],
|
||||
currentState: {
|
||||
isFetching: this.state.isFetching,
|
||||
itemCount: this.state.allItems.length,
|
||||
},
|
||||
})
|
||||
|
||||
if (message.state?.isFetching) {
|
||||
console.log("State indicates fetching, transitioning to FETCH_ITEMS")
|
||||
void this.transition({
|
||||
type: "FETCH_ITEMS",
|
||||
})
|
||||
} else if (message.state?.packageManagerItems) {
|
||||
console.log("State includes items, transitioning to FETCH_COMPLETE")
|
||||
void this.transition({
|
||||
type: "FETCH_COMPLETE",
|
||||
payload: { items: message.state.packageManagerItems },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle repository refresh completion
|
||||
if (message.type === "repositoryRefreshComplete" && message.url) {
|
||||
void this.transition({
|
||||
type: "REFRESH_SOURCE_COMPLETE",
|
||||
payload: { url: message.url },
|
||||
})
|
||||
}
|
||||
|
||||
// Handle package manager button clicks
|
||||
if (message.type === "packageManagerButtonClicked") {
|
||||
if (message.text) {
|
||||
// Error case
|
||||
void this.transition({ type: "FETCH_ERROR" })
|
||||
} else {
|
||||
// Refresh request
|
||||
void this.transition({ type: "FETCH_ITEMS" })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,673 @@
|
|||
import { PackageManagerViewStateManager } from "../PackageManagerViewStateManager"
|
||||
import { vscode } from "@/utils/vscode"
|
||||
import { ComponentType, PackageManagerItem, PackageManagerSource } from "@package-manager/types"
|
||||
|
||||
const createTestItem = (overrides = {}): PackageManagerItem => ({
|
||||
name: "test",
|
||||
type: "mode" as ComponentType,
|
||||
description: "Test mode",
|
||||
url: "https://github.com/test/repo",
|
||||
repoUrl: "https://github.com/test/repo",
|
||||
author: "Test Author",
|
||||
version: "1.0.0",
|
||||
sourceName: "Test Source",
|
||||
sourceUrl: "https://github.com/test/repo",
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createTestSources = (): PackageManagerSource[] => [
|
||||
{ url: "https://github.com/test/repo1", enabled: true },
|
||||
{ url: "https://github.com/test/repo2", enabled: true },
|
||||
{ url: "https://github.com/test/repo3", enabled: true },
|
||||
]
|
||||
|
||||
// Mock vscode.postMessage
|
||||
jest.mock("@/utils/vscode", () => ({
|
||||
vscode: {
|
||||
postMessage: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe("PackageManagerViewStateManager", () => {
|
||||
let manager: PackageManagerViewStateManager
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
manager = new PackageManagerViewStateManager()
|
||||
})
|
||||
|
||||
describe("Initial State", () => {
|
||||
it("should initialize with default state", () => {
|
||||
const state = manager.getState()
|
||||
expect(state).toEqual({
|
||||
allItems: [],
|
||||
isFetching: false,
|
||||
activeTab: "browse",
|
||||
refreshingUrls: [],
|
||||
sources: [],
|
||||
filters: {
|
||||
type: "",
|
||||
search: "",
|
||||
tags: [],
|
||||
},
|
||||
sortConfig: {
|
||||
by: "name",
|
||||
order: "asc",
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Fetch Transitions", () => {
|
||||
it("should handle FETCH_ITEMS transition", async () => {
|
||||
await manager.transition({ type: "FETCH_ITEMS" })
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "fetchPackageManagerItems",
|
||||
bool: true,
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.isFetching).toBe(true)
|
||||
})
|
||||
|
||||
it("should not start a new fetch if one is in progress", async () => {
|
||||
// Start first fetch
|
||||
await manager.transition({ type: "FETCH_ITEMS" })
|
||||
|
||||
// Try to start second fetch
|
||||
await manager.transition({ type: "FETCH_ITEMS" })
|
||||
|
||||
// postMessage should only be called once
|
||||
expect(vscode.postMessage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("should handle FETCH_COMPLETE transition", async () => {
|
||||
const testItems = [createTestItem()]
|
||||
|
||||
await manager.transition({ type: "FETCH_ITEMS" })
|
||||
await manager.transition({
|
||||
type: "FETCH_COMPLETE",
|
||||
payload: { items: testItems },
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.isFetching).toBe(false)
|
||||
expect(state.allItems).toEqual(testItems)
|
||||
})
|
||||
|
||||
it("should handle FETCH_ERROR transition", async () => {
|
||||
await manager.transition({ type: "FETCH_ITEMS" })
|
||||
await manager.transition({ type: "FETCH_ERROR" })
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.isFetching).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Race Conditions", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it("should handle rapid tab switching during initial load", async () => {
|
||||
// Start initial load
|
||||
await manager.transition({ type: "FETCH_ITEMS" })
|
||||
|
||||
// Quickly switch to sources tab
|
||||
await manager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "sources" },
|
||||
})
|
||||
|
||||
// Switch back to browse before load completes
|
||||
await manager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "browse" },
|
||||
})
|
||||
|
||||
// Complete the initial load
|
||||
await manager.handleMessage({
|
||||
type: "state",
|
||||
state: { packageManagerItems: [createTestItem()] },
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.activeTab).toBe("browse")
|
||||
expect(state.allItems).toHaveLength(1)
|
||||
expect(state.isFetching).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle rapid filtering during initial load", async () => {
|
||||
// Start initial load
|
||||
await manager.transition({ type: "FETCH_ITEMS" })
|
||||
|
||||
// Quickly apply filters
|
||||
await manager.transition({
|
||||
type: "UPDATE_FILTERS",
|
||||
payload: { filters: { type: "mode" } },
|
||||
})
|
||||
|
||||
// Complete the initial load
|
||||
await manager.handleMessage({
|
||||
type: "state",
|
||||
state: { packageManagerItems: [createTestItem()] },
|
||||
})
|
||||
|
||||
// Fast-forward past debounce time
|
||||
jest.advanceTimersByTime(300)
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.filters.type).toBe("mode")
|
||||
expect(state.allItems).toHaveLength(1)
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "filterPackageManagerItems",
|
||||
filters: expect.objectContaining({ type: "mode" }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle concurrent filter operations", async () => {
|
||||
// Reset mock before test
|
||||
;(vscode.postMessage as jest.Mock).mockClear()
|
||||
|
||||
// Apply first filter
|
||||
await manager.transition({
|
||||
type: "UPDATE_FILTERS",
|
||||
payload: { filters: { search: "test" } },
|
||||
})
|
||||
|
||||
// Wait a bit but not enough to trigger debounce
|
||||
jest.advanceTimersByTime(100)
|
||||
|
||||
// Apply second filter
|
||||
await manager.transition({
|
||||
type: "UPDATE_FILTERS",
|
||||
payload: { filters: { type: "mode" } },
|
||||
})
|
||||
|
||||
// Wait for debounce to complete
|
||||
jest.advanceTimersByTime(300)
|
||||
|
||||
// Should only send one filter message with combined filters
|
||||
expect(vscode.postMessage).toHaveBeenCalledTimes(1)
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "filterPackageManagerItems",
|
||||
filters: {
|
||||
search: "test",
|
||||
type: "mode",
|
||||
tags: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle rapid source deletions", async () => {
|
||||
// Reset mock before test
|
||||
;(vscode.postMessage as jest.Mock).mockClear()
|
||||
|
||||
// Create test sources
|
||||
const testSources = createTestSources()
|
||||
|
||||
// Set initial sources and wait for state update
|
||||
await manager.transition({
|
||||
type: "UPDATE_SOURCES",
|
||||
payload: { sources: testSources },
|
||||
})
|
||||
|
||||
// Delete all sources at once
|
||||
await manager.transition({
|
||||
type: "UPDATE_SOURCES",
|
||||
payload: { sources: [] },
|
||||
})
|
||||
|
||||
// Wait for state to settle
|
||||
jest.runAllTimers()
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.sources).toEqual([])
|
||||
|
||||
// Should send the final sources state to webview
|
||||
expect(vscode.postMessage).toHaveBeenLastCalledWith({
|
||||
type: "packageManagerSources",
|
||||
sources: [],
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle rapid source operations during fetch", async () => {
|
||||
// Start a fetch
|
||||
await manager.transition({ type: "FETCH_ITEMS" })
|
||||
|
||||
// Rapidly update sources while fetch is in progress
|
||||
const sources = [{ url: "https://github.com/test/repo1", enabled: true }]
|
||||
|
||||
await manager.transition({
|
||||
type: "UPDATE_SOURCES",
|
||||
payload: { sources },
|
||||
})
|
||||
|
||||
// Complete the fetch
|
||||
await manager.handleMessage({
|
||||
type: "state",
|
||||
state: { packageManagerItems: [createTestItem()] },
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.sources).toEqual(sources)
|
||||
expect(state.allItems).toHaveLength(1)
|
||||
expect(state.isFetching).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Error Handling", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it("should handle fetch timeout", async () => {
|
||||
await manager.transition({ type: "FETCH_ITEMS" })
|
||||
|
||||
// Fast-forward past the timeout
|
||||
jest.advanceTimersByTime(30000)
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.isFetching).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle invalid message types gracefully", () => {
|
||||
manager.handleMessage({ type: "invalidType" })
|
||||
const state = manager.getState()
|
||||
expect(state.isFetching).toBe(false)
|
||||
expect(state.allItems).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle invalid state message format", () => {
|
||||
manager.handleMessage({ type: "state", state: {} })
|
||||
const state = manager.getState()
|
||||
expect(state.allItems).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle invalid transition payloads", async () => {
|
||||
// @ts-ignore - Testing invalid payload
|
||||
await manager.transition({ type: "UPDATE_FILTERS", payload: { invalid: true } })
|
||||
const state = manager.getState()
|
||||
expect(state.filters).toEqual({
|
||||
type: "",
|
||||
search: "",
|
||||
tags: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Filter Behavior", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it("should debounce filter updates", async () => {
|
||||
// Reset mock before test
|
||||
;(vscode.postMessage as jest.Mock).mockClear()
|
||||
|
||||
// Apply first filter
|
||||
await manager.transition({
|
||||
type: "UPDATE_FILTERS",
|
||||
payload: { filters: { search: "test1" } },
|
||||
})
|
||||
|
||||
// Wait a bit but not enough to trigger debounce
|
||||
jest.advanceTimersByTime(100)
|
||||
|
||||
// Apply second filter
|
||||
await manager.transition({
|
||||
type: "UPDATE_FILTERS",
|
||||
payload: { filters: { search: "test2" } },
|
||||
})
|
||||
|
||||
// Wait a bit but not enough to trigger debounce
|
||||
jest.advanceTimersByTime(100)
|
||||
|
||||
// Apply third filter
|
||||
await manager.transition({
|
||||
type: "UPDATE_FILTERS",
|
||||
payload: { filters: { search: "test3" } },
|
||||
})
|
||||
|
||||
// Wait for debounce to complete
|
||||
jest.advanceTimersByTime(300)
|
||||
|
||||
// Should only send the last update
|
||||
expect(vscode.postMessage).toHaveBeenCalledTimes(1)
|
||||
expect(vscode.postMessage).toHaveBeenLastCalledWith({
|
||||
type: "filterPackageManagerItems",
|
||||
filters: {
|
||||
type: undefined,
|
||||
search: "test3",
|
||||
tags: undefined,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("should not send filter message if no filters are active", async () => {
|
||||
await manager.transition({
|
||||
type: "UPDATE_FILTERS",
|
||||
payload: {
|
||||
filters: {
|
||||
type: "",
|
||||
search: "",
|
||||
tags: [],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Fast-forward past debounce time
|
||||
jest.advanceTimersByTime(300)
|
||||
|
||||
// Should not send filter message
|
||||
expect(vscode.postMessage).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "filterPackageManagerItems",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Message Handling", () => {
|
||||
it("should handle repository refresh completion", () => {
|
||||
const url = "https://example.com/repo"
|
||||
|
||||
// First add URL to refreshing list
|
||||
manager.transition({
|
||||
type: "REFRESH_SOURCE",
|
||||
payload: { url },
|
||||
})
|
||||
|
||||
// Then handle completion message
|
||||
manager.handleMessage({
|
||||
type: "repositoryRefreshComplete",
|
||||
url,
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.refreshingUrls).not.toContain(url)
|
||||
})
|
||||
|
||||
it("should handle package manager button click with error", () => {
|
||||
manager.handleMessage({
|
||||
type: "packageManagerButtonClicked",
|
||||
text: "error",
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.isFetching).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle package manager button click for refresh", () => {
|
||||
manager.handleMessage({
|
||||
type: "packageManagerButtonClicked",
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.isFetching).toBe(true)
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "fetchPackageManagerItems",
|
||||
bool: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Tab Management", () => {
|
||||
it("should handle SET_ACTIVE_TAB transition", async () => {
|
||||
await manager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "sources" },
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.activeTab).toBe("sources")
|
||||
})
|
||||
|
||||
it("should trigger fetch when switching to browse tab", async () => {
|
||||
await manager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "browse" },
|
||||
})
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "fetchPackageManagerItems",
|
||||
bool: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("should not trigger fetch when switching to sources tab", async () => {
|
||||
await manager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "sources" },
|
||||
})
|
||||
|
||||
expect(vscode.postMessage).not.toHaveBeenCalledWith({
|
||||
type: "fetchPackageManagerItems",
|
||||
bool: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Fetch Timeout Handling", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it("should handle fetch timeout", async () => {
|
||||
await manager.transition({ type: "FETCH_ITEMS" })
|
||||
|
||||
// Fast-forward past the timeout
|
||||
jest.advanceTimersByTime(30000)
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.isFetching).toBe(false)
|
||||
})
|
||||
|
||||
it("should clear timeout on successful fetch", async () => {
|
||||
await manager.transition({ type: "FETCH_ITEMS" })
|
||||
|
||||
// Complete fetch before timeout
|
||||
await manager.transition({
|
||||
type: "FETCH_COMPLETE",
|
||||
payload: { items: [createTestItem()] },
|
||||
})
|
||||
|
||||
// Fast-forward past the timeout
|
||||
jest.advanceTimersByTime(30000)
|
||||
|
||||
// State should still reflect successful fetch
|
||||
const state = manager.getState()
|
||||
expect(state.isFetching).toBe(false)
|
||||
expect(state.allItems).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("should prevent concurrent fetches during timeout period", async () => {
|
||||
// Start first fetch
|
||||
await manager.transition({ type: "FETCH_ITEMS" })
|
||||
|
||||
// Attempt second fetch before timeout
|
||||
jest.advanceTimersByTime(15000)
|
||||
await manager.transition({ type: "FETCH_ITEMS" })
|
||||
|
||||
// postMessage should only be called once
|
||||
expect(vscode.postMessage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// Filter behavior tests are already covered in the previous describe block
|
||||
|
||||
describe("Source Management", () => {
|
||||
it("should handle UPDATE_SOURCES transition", async () => {
|
||||
const sources = [
|
||||
{ url: "https://github.com/test/repo", enabled: true },
|
||||
{ url: "https://github.com/test/repo2", enabled: false },
|
||||
]
|
||||
|
||||
await manager.transition({
|
||||
type: "UPDATE_SOURCES",
|
||||
payload: { sources },
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.sources).toEqual(sources)
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "packageManagerSources",
|
||||
sources,
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle REFRESH_SOURCE transition", async () => {
|
||||
const url = "https://github.com/test/repo"
|
||||
|
||||
await manager.transition({
|
||||
type: "REFRESH_SOURCE",
|
||||
payload: { url },
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.refreshingUrls).toContain(url)
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "refreshPackageManagerSource",
|
||||
url,
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle REFRESH_SOURCE_COMPLETE transition", async () => {
|
||||
const url = "https://github.com/test/repo"
|
||||
|
||||
// First add URL to refreshing list
|
||||
await manager.transition({
|
||||
type: "REFRESH_SOURCE",
|
||||
payload: { url },
|
||||
})
|
||||
|
||||
// Then complete the refresh
|
||||
await manager.transition({
|
||||
type: "REFRESH_SOURCE_COMPLETE",
|
||||
payload: { url },
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.refreshingUrls).not.toContain(url)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Filter Transitions", () => {
|
||||
it("should handle UPDATE_FILTERS transition", async () => {
|
||||
const filters = {
|
||||
type: "mode",
|
||||
search: "test",
|
||||
tags: ["tag1"],
|
||||
}
|
||||
|
||||
await manager.transition({
|
||||
type: "UPDATE_FILTERS",
|
||||
payload: { filters },
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.filters).toEqual(filters)
|
||||
|
||||
// Wait for debounce
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "filterPackageManagerItems",
|
||||
filters: {
|
||||
type: "mode",
|
||||
search: "test",
|
||||
tags: ["tag1"],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Sort Transitions", () => {
|
||||
it("should handle UPDATE_SORT transition", async () => {
|
||||
const sortConfig = {
|
||||
by: "lastUpdated" as const,
|
||||
order: "desc" as const,
|
||||
}
|
||||
|
||||
await manager.transition({
|
||||
type: "UPDATE_SORT",
|
||||
payload: { sortConfig },
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.sortConfig).toEqual(sortConfig)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Message Handling", () => {
|
||||
it("should handle state message with package manager items", () => {
|
||||
const testItems = [createTestItem()]
|
||||
|
||||
// We need to use any here since we're testing the raw message handling
|
||||
manager.handleMessage({
|
||||
type: "state",
|
||||
state: { packageManagerItems: testItems },
|
||||
} as any)
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.allItems).toEqual(testItems)
|
||||
})
|
||||
|
||||
it("should handle repositoryRefreshComplete message", () => {
|
||||
const url = "https://example.com/repo"
|
||||
|
||||
// First add URL to refreshing list
|
||||
manager.transition({
|
||||
type: "REFRESH_SOURCE",
|
||||
payload: { url },
|
||||
})
|
||||
|
||||
// Then handle completion message
|
||||
manager.handleMessage({
|
||||
type: "repositoryRefreshComplete",
|
||||
url,
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.refreshingUrls).not.toContain(url)
|
||||
})
|
||||
|
||||
it("should handle packageManagerButtonClicked message with error", () => {
|
||||
manager.handleMessage({
|
||||
type: "packageManagerButtonClicked",
|
||||
text: "error",
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.isFetching).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle packageManagerButtonClicked message for refresh", () => {
|
||||
manager.handleMessage({
|
||||
type: "packageManagerButtonClicked",
|
||||
})
|
||||
|
||||
const state = manager.getState()
|
||||
expect(state.isFetching).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { useState, useEffect } from "react"
|
||||
import { PackageManagerViewStateManager, ViewState } from "./PackageManagerViewStateManager"
|
||||
|
||||
export function useStateManager() {
|
||||
const [manager] = useState(() => {
|
||||
console.log("=== Creating PackageManagerViewStateManager ===")
|
||||
return new PackageManagerViewStateManager()
|
||||
})
|
||||
|
||||
const [state, setState] = useState(() => {
|
||||
const initialState = manager.getState()
|
||||
console.log("=== Initializing State ===", {
|
||||
allItems: initialState.allItems,
|
||||
itemsLength: initialState.allItems.length,
|
||||
isFetching: initialState.isFetching,
|
||||
activeTab: initialState.activeTab,
|
||||
})
|
||||
return initialState
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
console.log("=== Setting up state change subscription and message listener ===")
|
||||
let updateCount = 0
|
||||
|
||||
const handleStateChange = (newState: ViewState) => {
|
||||
updateCount++
|
||||
console.log(`=== State Update #${updateCount} Received ===`, {
|
||||
allItems: newState.allItems,
|
||||
itemsLength: newState.allItems.length,
|
||||
isFetching: newState.isFetching,
|
||||
activeTab: newState.activeTab,
|
||||
previousFetching: state.isFetching,
|
||||
stateChanged: JSON.stringify(newState) !== JSON.stringify(state),
|
||||
})
|
||||
setState(newState)
|
||||
}
|
||||
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
console.log("=== Message Event Received ===", {
|
||||
type: event.data?.type,
|
||||
hasState: !!event.data?.state,
|
||||
isFetching: event.data?.state?.isFetching,
|
||||
itemCount: event.data?.state?.packageManagerItems?.length,
|
||||
})
|
||||
manager.handleMessage(event.data)
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
const unsubscribe = manager.onStateChange(handleStateChange)
|
||||
|
||||
return () => {
|
||||
console.log(`=== Cleaning up state manager (processed ${updateCount} updates) ===`)
|
||||
window.removeEventListener("message", handleMessage)
|
||||
unsubscribe()
|
||||
manager.cleanup()
|
||||
}
|
||||
}, [manager, state])
|
||||
|
||||
return [state, manager] as const
|
||||
}
|
||||
|
|
@ -17,8 +17,10 @@
|
|||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@package-manager/*": ["../src/services/package-manager/*"],
|
||||
"@shared/*": ["../src/shared/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "../src/shared"]
|
||||
"include": ["src", "../src/shared", "../src/services"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue