mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-06 02:47:56 +00:00
Co-authored-by: Daniel Riccio <ricciodaniel98@gmail.com>
This commit is contained in:
parent
7a8483dc12
commit
d598fc3795
3 changed files with 423 additions and 4 deletions
|
|
@ -42,7 +42,9 @@ export function MarketplaceView({ stateManager, onDone }: MarketplaceViewProps)
|
|||
|
||||
// Listen for state changes to know when initial data arrives
|
||||
const unsubscribe = manager.onStateChange((newState) => {
|
||||
if (newState.allItems.length > 0 && !hasReceivedInitialState) {
|
||||
// Mark as received initial state when we get any state update
|
||||
// This prevents infinite loops and ensures proper state handling
|
||||
if (!hasReceivedInitialState && (newState.allItems.length > 0 || newState.displayItems !== undefined)) {
|
||||
setHasReceivedInitialState(true)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -97,7 +97,8 @@ export class MarketplaceViewStateManager {
|
|||
// Only create new arrays if they exist and have items
|
||||
const allItems = this.state.allItems.length ? [...this.state.allItems] : []
|
||||
// Ensure displayItems is always an array, never undefined
|
||||
const displayItems = this.state.displayItems ? [...this.state.displayItems] : []
|
||||
// If displayItems is undefined or null, fall back to allItems
|
||||
const displayItems = this.state.displayItems ? [...this.state.displayItems] : [...allItems]
|
||||
const tags = this.state.filters.tags.length ? [...this.state.filters.tags] : []
|
||||
|
||||
// Create minimal new state object
|
||||
|
|
@ -174,11 +175,20 @@ export class MarketplaceViewStateManager {
|
|||
break
|
||||
}
|
||||
|
||||
// Calculate display items based on current filters
|
||||
let newDisplayItems: MarketplaceItem[]
|
||||
if (this.isFilterActive()) {
|
||||
newDisplayItems = this.filterItems([...items])
|
||||
} else {
|
||||
// No filters active - show all items
|
||||
newDisplayItems = [...items]
|
||||
}
|
||||
|
||||
// Update allItems as source of truth
|
||||
this.state = {
|
||||
...this.state,
|
||||
allItems: [...items],
|
||||
displayItems: this.isFilterActive() ? this.filterItems([...items]) : [...items],
|
||||
displayItems: newDisplayItems,
|
||||
isFetching: false,
|
||||
}
|
||||
|
||||
|
|
@ -322,7 +332,17 @@ export class MarketplaceViewStateManager {
|
|||
// Always use the marketplace items from the extension when they're provided
|
||||
// This ensures fresh data is always displayed
|
||||
const items = [...marketplaceItems]
|
||||
const newDisplayItems = this.isFilterActive() ? this.filterItems(items) : items
|
||||
|
||||
// Calculate display items based on current filters
|
||||
// If no filters are active, show all items
|
||||
// If filters are active, apply filtering
|
||||
let newDisplayItems: MarketplaceItem[]
|
||||
if (this.isFilterActive()) {
|
||||
newDisplayItems = this.filterItems(items)
|
||||
} else {
|
||||
// No filters active - show all items
|
||||
newDisplayItems = items
|
||||
}
|
||||
|
||||
// Update state in a single operation
|
||||
this.state = {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,397 @@
|
|||
import { MarketplaceViewStateManager, ViewStateTransition } from "../MarketplaceViewStateManager"
|
||||
import { MarketplaceItem } from "@roo-code/types"
|
||||
|
||||
// Mock vscode module
|
||||
vi.mock("@/utils/vscode", () => ({
|
||||
vscode: {
|
||||
postMessage: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe("MarketplaceViewStateManager", () => {
|
||||
let stateManager: MarketplaceViewStateManager
|
||||
let mockStateChangeHandler: ReturnType<typeof vi.fn>
|
||||
|
||||
const mockMarketplaceItems: MarketplaceItem[] = [
|
||||
{
|
||||
id: "test-mcp-1",
|
||||
name: "Test MCP Server 1",
|
||||
description: "A test MCP server",
|
||||
type: "mcp",
|
||||
url: "https://example.com/test-mcp-1",
|
||||
content: "test content",
|
||||
tags: ["test", "mcp"],
|
||||
},
|
||||
{
|
||||
id: "test-mode-1",
|
||||
name: "Test Mode 1",
|
||||
description: "A test mode",
|
||||
type: "mode",
|
||||
content: "test content",
|
||||
tags: ["test", "mode"],
|
||||
},
|
||||
{
|
||||
id: "test-mcp-2",
|
||||
name: "Test MCP Server 2",
|
||||
description: "Another test MCP server",
|
||||
type: "mcp",
|
||||
url: "https://example.com/test-mcp-2",
|
||||
content: "test content",
|
||||
tags: ["test", "server"],
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(() => {
|
||||
stateManager = new MarketplaceViewStateManager()
|
||||
mockStateChangeHandler = vi.fn()
|
||||
stateManager.onStateChange(mockStateChangeHandler)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
stateManager.cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("initialization", () => {
|
||||
it("should initialize with default state", () => {
|
||||
const state = stateManager.getState()
|
||||
|
||||
expect(state.allItems).toEqual([])
|
||||
expect(state.displayItems).toEqual([])
|
||||
expect(state.isFetching).toBe(false)
|
||||
expect(state.activeTab).toBe("mcp")
|
||||
expect(state.filters).toEqual({
|
||||
type: "",
|
||||
search: "",
|
||||
tags: [],
|
||||
})
|
||||
})
|
||||
|
||||
it("should ensure displayItems is never undefined in getState", () => {
|
||||
const state = stateManager.getState()
|
||||
|
||||
expect(state.displayItems).toBeDefined()
|
||||
expect(Array.isArray(state.displayItems)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("displayItems initialization fix", () => {
|
||||
it("should fall back to allItems when displayItems is undefined", () => {
|
||||
// Simulate the scenario where displayItems might be undefined
|
||||
const transition: ViewStateTransition = {
|
||||
type: "FETCH_COMPLETE",
|
||||
payload: { items: mockMarketplaceItems },
|
||||
}
|
||||
|
||||
stateManager.transition(transition)
|
||||
const state = stateManager.getState()
|
||||
|
||||
// Verify that displayItems is properly initialized with allItems when no filters are active
|
||||
expect(state.displayItems).toEqual(mockMarketplaceItems)
|
||||
expect(state.allItems).toEqual(mockMarketplaceItems)
|
||||
})
|
||||
|
||||
it("should ensure displayItems defaults to allItems when no filters are active", async () => {
|
||||
// Handle message with marketplace items (simulating the fix scenario)
|
||||
const message = {
|
||||
type: "state",
|
||||
state: {
|
||||
marketplaceItems: mockMarketplaceItems,
|
||||
},
|
||||
}
|
||||
|
||||
await stateManager.handleMessage(message)
|
||||
const state = stateManager.getState()
|
||||
|
||||
// Verify the fix: displayItems should equal allItems when no filters are active
|
||||
expect(state.displayItems).toEqual(mockMarketplaceItems)
|
||||
expect(state.allItems).toEqual(mockMarketplaceItems)
|
||||
expect(state.displayItems?.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it("should prevent marketplace blanking by ensuring displayItems is never empty when allItems has content", async () => {
|
||||
// This test specifically addresses the bug described in the PR
|
||||
const message = {
|
||||
type: "state",
|
||||
state: {
|
||||
marketplaceItems: mockMarketplaceItems,
|
||||
},
|
||||
}
|
||||
|
||||
await stateManager.handleMessage(message)
|
||||
const state = stateManager.getState()
|
||||
|
||||
// The key fix: displayItems should never be empty when allItems has content and no filters are active
|
||||
expect(state.allItems.length).toBeGreaterThan(0)
|
||||
expect(state.displayItems?.length).toBeGreaterThan(0)
|
||||
expect(state.displayItems).toEqual(state.allItems)
|
||||
})
|
||||
})
|
||||
|
||||
describe("state change notifications", () => {
|
||||
it("should notify handlers when marketplace items are loaded", async () => {
|
||||
const message = {
|
||||
type: "state",
|
||||
state: {
|
||||
marketplaceItems: mockMarketplaceItems,
|
||||
},
|
||||
}
|
||||
|
||||
await stateManager.handleMessage(message)
|
||||
|
||||
expect(mockStateChangeHandler).toHaveBeenCalled()
|
||||
const notifiedState = mockStateChangeHandler.mock.calls[0][0]
|
||||
expect(notifiedState.allItems).toEqual(mockMarketplaceItems)
|
||||
expect(notifiedState.displayItems).toEqual(mockMarketplaceItems)
|
||||
})
|
||||
|
||||
it("should prevent infinite loops by properly handling initial state", async () => {
|
||||
// Simulate multiple rapid state updates that could cause infinite loops
|
||||
const message1 = {
|
||||
type: "state",
|
||||
state: {
|
||||
marketplaceItems: [],
|
||||
},
|
||||
}
|
||||
|
||||
const message2 = {
|
||||
type: "state",
|
||||
state: {
|
||||
marketplaceItems: mockMarketplaceItems,
|
||||
},
|
||||
}
|
||||
|
||||
await stateManager.handleMessage(message1)
|
||||
await stateManager.handleMessage(message2)
|
||||
|
||||
// Should have been called twice, not infinitely
|
||||
expect(mockStateChangeHandler).toHaveBeenCalledTimes(2)
|
||||
|
||||
const finalState = stateManager.getState()
|
||||
expect(finalState.displayItems).toEqual(mockMarketplaceItems)
|
||||
})
|
||||
})
|
||||
|
||||
describe("filtering behavior", () => {
|
||||
beforeEach(async () => {
|
||||
// Set up initial state with items
|
||||
const message = {
|
||||
type: "state",
|
||||
state: {
|
||||
marketplaceItems: mockMarketplaceItems,
|
||||
},
|
||||
}
|
||||
await stateManager.handleMessage(message)
|
||||
})
|
||||
|
||||
it("should show all items when no filters are active", () => {
|
||||
const state = stateManager.getState()
|
||||
|
||||
expect(stateManager.isFilterActive()).toBe(false)
|
||||
expect(state.displayItems).toEqual(mockMarketplaceItems)
|
||||
})
|
||||
|
||||
it("should filter items when filters are applied", async () => {
|
||||
// First update the filters
|
||||
const filterTransition: ViewStateTransition = {
|
||||
type: "UPDATE_FILTERS",
|
||||
payload: {
|
||||
filters: { type: "mcp" },
|
||||
},
|
||||
}
|
||||
|
||||
await stateManager.transition(filterTransition)
|
||||
|
||||
// Then simulate a state message that would trigger filtering
|
||||
const message = {
|
||||
type: "state",
|
||||
state: {
|
||||
marketplaceItems: mockMarketplaceItems,
|
||||
},
|
||||
}
|
||||
await stateManager.handleMessage(message)
|
||||
|
||||
const state = stateManager.getState()
|
||||
|
||||
expect(stateManager.isFilterActive()).toBe(true)
|
||||
expect(state.displayItems?.length).toBe(2) // Only MCP items
|
||||
expect(state.displayItems?.every((item) => item.type === "mcp")).toBe(true)
|
||||
})
|
||||
|
||||
it("should restore all items when filters are cleared", async () => {
|
||||
// First apply a filter
|
||||
await stateManager.transition({
|
||||
type: "UPDATE_FILTERS",
|
||||
payload: { filters: { type: "mcp" } },
|
||||
})
|
||||
|
||||
// Simulate state message with filter active
|
||||
let message = {
|
||||
type: "state",
|
||||
state: {
|
||||
marketplaceItems: mockMarketplaceItems,
|
||||
},
|
||||
}
|
||||
await stateManager.handleMessage(message)
|
||||
|
||||
// Then clear the filter
|
||||
await stateManager.transition({
|
||||
type: "UPDATE_FILTERS",
|
||||
payload: { filters: { type: "" } },
|
||||
})
|
||||
|
||||
// Simulate state message with filter cleared
|
||||
message = {
|
||||
type: "state",
|
||||
state: {
|
||||
marketplaceItems: mockMarketplaceItems,
|
||||
},
|
||||
}
|
||||
await stateManager.handleMessage(message)
|
||||
|
||||
const state = stateManager.getState()
|
||||
expect(stateManager.isFilterActive()).toBe(false)
|
||||
expect(state.displayItems).toEqual(mockMarketplaceItems)
|
||||
})
|
||||
|
||||
it("should handle search filters correctly", async () => {
|
||||
await stateManager.transition({
|
||||
type: "UPDATE_FILTERS",
|
||||
payload: { filters: { search: "Mode" } },
|
||||
})
|
||||
|
||||
// Simulate state message to trigger filtering
|
||||
const message = {
|
||||
type: "state",
|
||||
state: {
|
||||
marketplaceItems: mockMarketplaceItems,
|
||||
},
|
||||
}
|
||||
await stateManager.handleMessage(message)
|
||||
|
||||
const state = stateManager.getState()
|
||||
expect(state.displayItems?.length).toBe(1)
|
||||
expect(state.displayItems?.[0].name).toBe("Test Mode 1")
|
||||
})
|
||||
|
||||
it("should handle tag filters correctly", async () => {
|
||||
await stateManager.transition({
|
||||
type: "UPDATE_FILTERS",
|
||||
payload: { filters: { tags: ["server"] } },
|
||||
})
|
||||
|
||||
// Simulate state message to trigger filtering
|
||||
const message = {
|
||||
type: "state",
|
||||
state: {
|
||||
marketplaceItems: mockMarketplaceItems,
|
||||
},
|
||||
}
|
||||
await stateManager.handleMessage(message)
|
||||
|
||||
const state = stateManager.getState()
|
||||
expect(state.displayItems?.length).toBe(1)
|
||||
expect(state.displayItems?.[0].name).toBe("Test MCP Server 2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("tab switching", () => {
|
||||
it("should update active tab", async () => {
|
||||
await stateManager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "mode" },
|
||||
})
|
||||
|
||||
const state = stateManager.getState()
|
||||
expect(state.activeTab).toBe("mode")
|
||||
})
|
||||
|
||||
it("should preserve items when switching tabs", async () => {
|
||||
// Load items first
|
||||
const message = {
|
||||
type: "state",
|
||||
state: {
|
||||
marketplaceItems: mockMarketplaceItems,
|
||||
},
|
||||
}
|
||||
await stateManager.handleMessage(message)
|
||||
|
||||
// Switch tab
|
||||
await stateManager.transition({
|
||||
type: "SET_ACTIVE_TAB",
|
||||
payload: { tab: "mode" },
|
||||
})
|
||||
|
||||
const state = stateManager.getState()
|
||||
expect(state.activeTab).toBe("mode")
|
||||
expect(state.allItems).toEqual(mockMarketplaceItems)
|
||||
expect(state.displayItems).toEqual(mockMarketplaceItems)
|
||||
})
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should handle empty or invalid messages gracefully", async () => {
|
||||
await stateManager.handleMessage(null)
|
||||
await stateManager.handleMessage({})
|
||||
await stateManager.handleMessage({ type: "invalidType" })
|
||||
|
||||
const state = stateManager.getState()
|
||||
expect(state.allItems).toEqual([])
|
||||
expect(state.displayItems).toEqual([])
|
||||
})
|
||||
|
||||
it("should handle fetch errors", async () => {
|
||||
// First load some items
|
||||
const message = {
|
||||
type: "state",
|
||||
state: {
|
||||
marketplaceItems: mockMarketplaceItems,
|
||||
},
|
||||
}
|
||||
await stateManager.handleMessage(message)
|
||||
|
||||
// Then trigger an error
|
||||
await stateManager.transition({ type: "FETCH_ERROR" })
|
||||
|
||||
const state = stateManager.getState()
|
||||
expect(state.isFetching).toBe(false)
|
||||
// Items should be preserved during error
|
||||
expect(state.allItems).toEqual(mockMarketplaceItems)
|
||||
})
|
||||
})
|
||||
|
||||
describe("state copying and immutability", () => {
|
||||
it("should return new arrays in getState to prevent mutation", () => {
|
||||
const state1 = stateManager.getState()
|
||||
const state2 = stateManager.getState()
|
||||
|
||||
expect(state1.allItems).not.toBe(state2.allItems)
|
||||
expect(state1.displayItems).not.toBe(state2.displayItems)
|
||||
expect(state1.filters.tags).not.toBe(state2.filters.tags)
|
||||
})
|
||||
|
||||
it("should not mutate original state when modifying returned state", async () => {
|
||||
const message = {
|
||||
type: "state",
|
||||
state: {
|
||||
marketplaceItems: mockMarketplaceItems,
|
||||
},
|
||||
}
|
||||
await stateManager.handleMessage(message)
|
||||
|
||||
const state = stateManager.getState()
|
||||
state.allItems.push({
|
||||
id: "mutated",
|
||||
name: "Mutated Item",
|
||||
description: "Should not affect original",
|
||||
type: "mcp",
|
||||
url: "https://example.com/mutated",
|
||||
content: "test",
|
||||
})
|
||||
|
||||
const newState = stateManager.getState()
|
||||
expect(newState.allItems.length).toBe(mockMarketplaceItems.length)
|
||||
expect(newState.allItems.find((item) => item.id === "mutated")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue