Skip to content
Merged
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
2 changes: 1 addition & 1 deletion crates/cratebay-gui/src/components/ui/badge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,4 @@ function Badge({
)
}

export { Badge, badgeVariants }
export { Badge }
2 changes: 1 addition & 1 deletion crates/cratebay-gui/src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,4 +61,4 @@ function Button({
)
}

export { Button, buttonVariants }
export { Button }
2 changes: 1 addition & 1 deletion crates/cratebay-gui/src/components/ui/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,4 @@ function TabsContent({
)
}

export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
export { Tabs, TabsList, TabsTrigger, TabsContent }
17 changes: 12 additions & 5 deletions crates/cratebay-gui/src/hooks/useVolumes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,16 +64,23 @@ export function useVolumes() {
} catch (e) {
if (reqId !== latestReqId.current) return
setError(String(e))
} finally {
if (reqId !== latestReqId.current) return
}
if (reqId === latestReqId.current) {
setLoading(false)
}
}, [])

useEffect(() => {
fetchVolumes()
const iv = setInterval(fetchVolumes, 5000)
return () => clearInterval(iv)
const initialFetch = setTimeout(() => {
void fetchVolumes()
}, 0)
const iv = setInterval(() => {
void fetchVolumes()
}, 5000)
return () => {
clearTimeout(initialFetch)
clearInterval(iv)
}
}, [fetchVolumes])

const createVolume = useCallback(async (name: string, driver: string) => {
Expand Down
9 changes: 5 additions & 4 deletions crates/cratebay-gui/src/pages/__tests__/Dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ describe("Dashboard", () => {

expect(screen.getByText("web-server")).toBeInTheDocument()
expect(screen.getByText("api-server")).toBeInTheDocument()
expect(screen.getByText(/Running \(2\)/)).toBeInTheDocument()
expect(screen.getByText(t("running"))).toBeInTheDocument()
expect(screen.getByText("2", { selector: ".dash-section-count" })).toBeInTheDocument()
})

it("does not show running containers section when none are running", () => {
Expand All @@ -156,7 +157,7 @@ describe("Dashboard", () => {
/>
)

const viewAll = screen.getByText(/View all \(7\)/)
const viewAll = screen.getByText(t("viewAll"))
expect(viewAll).toBeInTheDocument()

await user.click(viewAll)
Expand All @@ -171,8 +172,8 @@ describe("Dashboard", () => {
<Dashboard {...defaultProps} containers={running} running={running} />
)

// Should only render 5 container-card elements in the running section
const containerCards = document.querySelectorAll(".container-card")
// Should only render 5 running-item elements in the running section
const containerCards = document.querySelectorAll(".dash-running-item")
expect(containerCards.length).toBe(5)
})

Expand Down
121 changes: 77 additions & 44 deletions crates/cratebay-gui/src/pages/__tests__/Images.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,56 +58,78 @@ const defaultProps = {
t,
}

const renderImages = (props: Partial<typeof defaultProps> = {}) => {
render(<Images {...defaultProps} {...props} />)
}

const openSearchTab = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(
screen.getByRole("button", { name: new RegExp(t("searchImages"), "i") })
)
}

describe("Images", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(invoke).mockResolvedValue([])
})

it("renders the search input and buttons", () => {
render(<Images {...defaultProps} />)
it("renders the search input and buttons", async () => {
const user = userEvent.setup()
renderImages()
await openSearchTab(user)

expect(screen.getByPlaceholderText(t("searchImages"))).toBeInTheDocument()
expect(screen.getByText(t("search"))).toBeInTheDocument()
})

it("shows the local images section header", () => {
render(<Images {...defaultProps} />)
renderImages()

expect(screen.getByText(t("localImages"))).toBeInTheDocument()
expect(
screen.getByRole("button", { name: new RegExp(t("localImages"), "i") })
).toBeInTheDocument()
})

it("shows empty search state with hint", () => {
render(<Images {...defaultProps} />)
it("shows empty search state with hint", async () => {
const user = userEvent.setup()
renderImages()
await openSearchTab(user)

expect(screen.getByText(t("searchHint"))).toBeInTheDocument()
})

it("calls onSearch when the search button is clicked", async () => {
const user = userEvent.setup()
const onSearch = vi.fn()
render(<Images {...defaultProps} imgQuery="nginx" onSearch={onSearch} />)
renderImages({ imgQuery: "nginx", onSearch })
await openSearchTab(user)

const searchBtn = screen.getByText(t("search"))
await user.click(searchBtn)

expect(onSearch).toHaveBeenCalled()
})

it("disables search button when imgQuery is empty", () => {
render(<Images {...defaultProps} imgQuery="" />)
it("disables search button when imgQuery is empty", async () => {
const user = userEvent.setup()
renderImages({ imgQuery: "" })
await openSearchTab(user)

const searchBtn = screen.getByText(t("search"))
expect(searchBtn).toBeDisabled()
})

it("disables search button while searching", () => {
render(<Images {...defaultProps} imgQuery="nginx" imgSearching={true} />)
it("disables search button while searching", async () => {
const user = userEvent.setup()
renderImages({ imgQuery: "nginx", imgSearching: true })
await openSearchTab(user)

expect(screen.getByText(t("searching"))).toBeDisabled()
})

it("renders search results as cards", () => {
it("renders search results as cards", async () => {
const user = userEvent.setup()
const results = [
mockSearchResult(),
mockSearchResult({
Expand All @@ -120,38 +142,47 @@ describe("Images", () => {
}),
]

render(<Images {...defaultProps} imgResults={results} />)
renderImages({ imgResults: results })
await openSearchTab(user)

expect(screen.getByText("nginx")).toBeInTheDocument()
expect(screen.getByText("Official Nginx image")).toBeInTheDocument()
expect(screen.getByText("quay.io/coreos/etcd")).toBeInTheDocument()
expect(screen.getByText("etcd service")).toBeInTheDocument()
})

it("shows official badge for official images", () => {
it("shows official badge for official images", async () => {
const user = userEvent.setup()
const results = [mockSearchResult({ official: true })]
render(<Images {...defaultProps} imgResults={results} />)
renderImages({ imgResults: results })
await openSearchTab(user)

expect(screen.getByText(t("official"))).toBeInTheDocument()
})

it("does not show official badge for non-official images", () => {
it("does not show official badge for non-official images", async () => {
const user = userEvent.setup()
const results = [mockSearchResult({ official: false })]
render(<Images {...defaultProps} imgResults={results} />)
renderImages({ imgResults: results })
await openSearchTab(user)

expect(screen.queryByText(t("official"))).not.toBeInTheDocument()
})

it("shows source badge on result cards", () => {
it("shows source badge on result cards", async () => {
const user = userEvent.setup()
const results = [mockSearchResult({ source: "dockerhub" })]
render(<Images {...defaultProps} imgResults={results} />)
renderImages({ imgResults: results })
await openSearchTab(user)

expect(screen.getByText("dockerhub")).toBeInTheDocument()
})

it("renders run and tags buttons on each result card", () => {
it("renders run and tags buttons on each result card", async () => {
const user = userEvent.setup()
const results = [mockSearchResult()]
render(<Images {...defaultProps} imgResults={results} />)
renderImages({ imgResults: results })
await openSearchTab(user)

expect(screen.getAllByText(t("run")).length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText(t("tags")).length).toBeGreaterThanOrEqual(1)
Expand All @@ -162,7 +193,8 @@ describe("Images", () => {
const onTags = vi.fn()
const results = [mockSearchResult({ reference: "quay.io/coreos/etcd" })]

render(<Images {...defaultProps} imgResults={results} onTags={onTags} />)
renderImages({ imgResults: results, onTags })
await openSearchTab(user)

// Find the tags button within the search result card
const tagsButtons = screen.getAllByText(t("tags"))
Expand All @@ -173,14 +205,13 @@ describe("Images", () => {
expect(onTags).toHaveBeenCalledWith("quay.io/coreos/etcd")
})

it("displays tags when available", () => {
render(
<Images
{...defaultProps}
imgTags={["latest", "1.0", "2.0"]}
imgTagsRef="quay.io/coreos/etcd"
/>
)
it("displays tags when available", async () => {
const user = userEvent.setup()
renderImages({
imgTags: ["latest", "1.0", "2.0"],
imgTagsRef: "quay.io/coreos/etcd",
})
await openSearchTab(user)

expect(screen.getByText(/Tags/)).toBeInTheDocument()
expect(screen.getByText("latest")).toBeInTheDocument()
Expand All @@ -189,14 +220,14 @@ describe("Images", () => {
})

it("shows the import image button", () => {
render(<Images {...defaultProps} />)
renderImages()

expect(screen.getByText(t("importImage"))).toBeInTheDocument()
})

it("opens the import/push modal when import button is clicked", async () => {
const user = userEvent.setup()
render(<Images {...defaultProps} />)
renderImages()

const importBtn = screen.getByText(t("importImage"))
await user.click(importBtn)
Expand All @@ -206,17 +237,15 @@ describe("Images", () => {
})

it("shows error message when imgError is set", () => {
render(<Images {...defaultProps} imgError="Something went wrong" />)
renderImages({ imgError: "Something went wrong" })

expect(screen.getByText("Something went wrong")).toBeInTheDocument()
})

it("dismisses error when dismiss button is clicked", async () => {
const user = userEvent.setup()
const setImgError = vi.fn()
render(
<Images {...defaultProps} imgError="Some error" setImgError={setImgError} />
)
renderImages({ imgError: "Some error", setImgError })

// The error inline has a dismiss button (x)
const dismissBtns = document.querySelectorAll(".error-inline-dismiss")
Expand All @@ -238,7 +267,7 @@ describe("Images", () => {
},
])

render(<Images {...defaultProps} />)
renderImages()

// Wait for local images to load
const removeBtn = await screen.findByTitle(t("removeImage"))
Expand Down Expand Up @@ -269,7 +298,7 @@ describe("Images", () => {
return undefined
})

render(<Images {...defaultProps} />)
renderImages()

// Wait for local images to load and click remove
const removeBtn = await screen.findByTitle(t("removeImage"))
Expand All @@ -282,33 +311,37 @@ describe("Images", () => {
expect(invoke).toHaveBeenCalledWith("image_remove", { id: "nginx:latest" })
})

it("shows source filter dropdown", () => {
render(<Images {...defaultProps} />)
it("shows source filter dropdown", async () => {
const user = userEvent.setup()
renderImages()
await openSearchTab(user)

expect(screen.getByText(t("sourceAll"))).toBeInTheDocument()
})

it("formats pull counts with K and M suffixes", () => {
it("formats pull counts with K and M suffixes", async () => {
const user = userEvent.setup()
const results = [
mockSearchResult({ reference: "nginx-official", pulls: 5000000 }),
mockSearchResult({ reference: "small-image", pulls: 1500, source: "quay" }),
]

render(<Images {...defaultProps} imgResults={results} />)
renderImages({ imgResults: results })
await openSearchTab(user)

expect(screen.getByText("5.0M")).toBeInTheDocument()
expect(screen.getByText("1.5K")).toBeInTheDocument()
})

it("shows local image filter input", () => {
render(<Images {...defaultProps} />)
renderImages()

expect(screen.getByPlaceholderText(t("filterLocalImages"))).toBeInTheDocument()
})

it("shows no local images message when list is empty", async () => {
vi.mocked(invoke).mockResolvedValue([])
render(<Images {...defaultProps} />)
renderImages()

expect(await screen.findByText(t("noLocalImages"))).toBeInTheDocument()
})
Expand Down