Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 216 additions & 0 deletions apps/reactotron-app/src/renderer/pages/customCommands/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import React from "react"
import { fireEvent, render, screen } from "@testing-library/react"
import { CustomCommandsContext } from "reactotron-core-ui"
import type { CustomCommand } from "reactotron-core-ui"
import { ThemeProvider } from "styled-components"

import StandaloneContext from "../../contexts/Standalone"
import CustomCommands from "."

jest.mock("reactotron-core-ui", () => {
const ReactActual = jest.requireActual<typeof import("react")>("react")

return {
CustomCommandsContext: ReactActual.createContext({
customCommands: [],
sendCustomCommand: null,
}),
EmptyState: ({ title, children }) =>
ReactActual.createElement(
"div",
null,
ReactActual.createElement("span", null, title),
children
),
Header: ({ title, actions, children }) =>
ReactActual.createElement(
"div",
null,
title,
actions.map((action) =>
ReactActual.createElement(
"button",
{
"data-tip": action.tip,
key: action.tip,
onClick: action.onClick,
type: "button",
},
action.tip
)
),
children
),
}
})

jest.mock("../../contexts/Standalone", () => {
const ReactActual = jest.requireActual<typeof import("react")>("react")

return {
__esModule: true,
default: ReactActual.createContext(null),
}
})

const theme = {
backgroundLighter: "#333",
backgroundSubtleDark: "#111",
foreground: "#aaa",
foregroundDark: "#888",
}

const connections = {
ios: {
id: 1,
clientId: "ios-client",
platform: "ios" as const,
commands: [],
connected: true,
},
android: {
id: 2,
clientId: "android-client",
platform: "android" as const,
commands: [],
connected: true,
},
}

const commands: CustomCommand[] = [
{
clientId: connections.ios.clientId,
id: "ios-command",
command: "reload-ios",
title: "Reload iOS",
description: "Reload the selected iOS app",
},
{
clientId: connections.android.clientId,
id: "android-command",
command: "clear-android-cache",
title: "Clear Android Cache",
description: "Only available on the Android app",
},
]

interface ProvidersProps {
children: React.ReactNode
customCommands?: CustomCommand[]
selectedClientId: keyof typeof connections
sendCustomCommand?: jest.Mock
}

function Providers({
children,
customCommands = commands,
selectedClientId,
sendCustomCommand = jest.fn(),
}: ProvidersProps) {
return (
<ThemeProvider theme={theme}>
<StandaloneContext.Provider
value={{
serverStatus: "started",
connections: Object.values(connections),
selectedConnection: connections[selectedClientId],
selectConnection: jest.fn(),
mcpStatus: "stopped",
mcpPort: null,
toggleMcp: jest.fn(),
mcpRedactionEnforced: true,
openMcpSettings: jest.fn(),
closeMcpSettings: jest.fn(),
mcpSettingsOpen: false,
updateMcpRedactionConfig: jest.fn(),
mcpRedactionConfig: {
defaults: {
sensitiveKeys: [],
statePathPatterns: [],
valuePatterns: [],
},
allowClientDisable: false,
allowClientRemoveRules: false,
},
}}
>
<CustomCommandsContext.Provider value={{ customCommands, sendCustomCommand }}>
{children}
</CustomCommandsContext.Provider>
</StandaloneContext.Provider>
</ThemeProvider>
)
}

function renderPage(props: Omit<ProvidersProps, "children">) {
return render(
<Providers {...props}>
<CustomCommands />
</Providers>
)
}

describe("CustomCommands", () => {
it("shows and sends only commands from the selected connection", () => {
const sendCustomCommand = jest.fn()

renderPage({ selectedClientId: "ios", sendCustomCommand })

expect(screen.getByText("Reload iOS")).toBeTruthy()
expect(screen.queryByText("Clear Android Cache")).toBeNull()
expect(screen.getAllByText("Send Command")).toHaveLength(1)

fireEvent.click(screen.getByText("Send Command"))

expect(sendCustomCommand).toHaveBeenCalledWith("reload-ios", {})
})

it("updates the command list when the selected connection changes", () => {
const view = renderPage({ selectedClientId: "ios" })

expect(screen.getByText("Reload iOS")).toBeTruthy()
expect(screen.queryByText("Clear Android Cache")).toBeNull()

view.rerender(
<Providers selectedClientId="android">
<CustomCommands />
</Providers>
)

expect(screen.queryByText("Reload iOS")).toBeNull()
expect(screen.getByText("Clear Android Cache")).toBeTruthy()
})

it("shows the empty state when only another connection has commands", () => {
renderPage({
selectedClientId: "android",
customCommands: [commands[0]],
})

expect(screen.getByText("No Custom Commands")).toBeTruthy()
expect(screen.queryByText("Reload iOS")).toBeNull()
})

it("does not reveal another connection's commands through search", () => {
const { container } = renderPage({ selectedClientId: "ios" })

fireEvent.click(container.querySelector('[data-tip="Search"]'))
fireEvent.change(screen.getByRole("textbox"), { target: { value: "android" } })

expect(screen.queryByText("Clear Android Cache")).toBeNull()
expect(screen.queryByText("Reload iOS")).toBeNull()
})

it("keeps single-connection search results working", () => {
const { container } = renderPage({
selectedClientId: "ios",
customCommands: [commands[0]],
})

fireEvent.click(container.querySelector('[data-tip="Search"]'))
fireEvent.change(screen.getByRole("textbox"), { target: { value: "reload" } })

expect(screen.getByText("Reload iOS")).toBeTruthy()
expect(screen.queryByText("No Custom Commands")).toBeNull()
})
})
12 changes: 9 additions & 3 deletions apps/reactotron-app/src/renderer/pages/customCommands/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { MdSearch } from "react-icons/md"
import { FaMagic } from "react-icons/fa"
import { produce } from "immer"

import StandaloneContext from "../../contexts/Standalone"

const Container = styled.div`
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -170,17 +172,21 @@ function CustomCommands() {
const [search, setSearch] = useState("")

const { customCommands, sendCustomCommand } = useContext(CustomCommandsContext)
const { selectedConnection } = useContext(StandaloneContext)
const selectedCustomCommands = customCommands.filter(
(customCommand) => customCommand.clientId === selectedConnection?.clientId
)

const lowerSearch = search.toLowerCase()
const filteredCustomCommands =
search !== ""
? customCommands.filter(
? selectedCustomCommands.filter(
(cc) =>
cc.command.toLowerCase().indexOf(lowerSearch) > -1 ||
(cc.title || "").toLowerCase().indexOf(lowerSearch) > -1 ||
(cc.description || "").toLowerCase().indexOf(lowerSearch) > -1
)
: customCommands
: selectedCustomCommands

return (
<Container>
Expand All @@ -205,7 +211,7 @@ function CustomCommands() {
)}
</Header>
<CommandsContainer>
{customCommands.length === 0 ? (
{selectedCustomCommands.length === 0 ? (
<EmptyState icon={FaMagic} title="No Custom Commands">
When your app registers a custom command it will show here!
</EmptyState>
Expand Down