PackageManagerView tests all passing

This commit is contained in:
Smartsheet-JB-Brown 2025-04-14 21:59:22 -07:00
parent 72d9be2a6c
commit beb151e1b0
9 changed files with 1741 additions and 432 deletions

View file

@ -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)}>

View file

@ -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()
})
})

View file

@ -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") {

View file

@ -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")

View file

@ -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", () => {

View file

@ -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" })
}
}
}
}

View file

@ -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)
})
})
})

View file

@ -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
}

View file

@ -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"]
}