refactor(frontend): Auth (#8308)

This commit is contained in:
sp.wack
2025-05-07 08:20:23 +04:00
committed by GitHub
parent ac0dab41dd
commit d3f6508e32
39 changed files with 302 additions and 390 deletions

View File

@@ -1,8 +1,9 @@
import { describe, expect, it, vi, beforeEach } from "vitest"; import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ActionSuggestions } from "#/components/features/chat/action-suggestions"; import { ActionSuggestions } from "#/components/features/chat/action-suggestions";
import { useAuth } from "#/context/auth-context"; import OpenHands from "#/api/open-hands";
import { useSelector } from "react-redux"; import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
// Mock dependencies // Mock dependencies
vi.mock("posthog-js", () => ({ vi.mock("posthog-js", () => ({
@@ -11,8 +12,12 @@ vi.mock("posthog-js", () => ({
}, },
})); }));
const { useSelectorMock } = vi.hoisted(() => ({
useSelectorMock: vi.fn(),
}));
vi.mock("react-redux", () => ({ vi.mock("react-redux", () => ({
useSelector: vi.fn(), useSelector: useSelectorMock,
})); }));
vi.mock("#/context/auth-context", () => ({ vi.mock("#/context/auth-context", () => ({
@@ -24,34 +29,46 @@ vi.mock("react-i18next", () => ({
useTranslation: () => ({ useTranslation: () => ({
t: (key: string) => { t: (key: string) => {
const translations: Record<string, string> = { const translations: Record<string, string> = {
"ACTION$PUSH_TO_BRANCH": "Push to Branch", ACTION$PUSH_TO_BRANCH: "Push to Branch",
"ACTION$PUSH_CREATE_PR": "Push & Create PR", ACTION$PUSH_CREATE_PR: "Push & Create PR",
"ACTION$PUSH_CHANGES_TO_PR": "Push Changes to PR" ACTION$PUSH_CHANGES_TO_PR: "Push Changes to PR",
}; };
return translations[key] || key; return translations[key] || key;
}, },
}), }),
})); }));
const renderActionSuggestions = () =>
render(<ActionSuggestions onSuggestionsClick={() => {}} />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
describe("ActionSuggestions", () => { describe("ActionSuggestions", () => {
// Setup mocks for each test // Setup mocks for each test
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
(useAuth as any).mockReturnValue({ getSettingsSpy.mockResolvedValue({
providersAreSet: true, ...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: "some-token",
},
}); });
(useSelector as any).mockReturnValue({ useSelectorMock.mockReturnValue({
selectedRepository: "test-repo", selectedRepository: "test-repo",
}); });
}); });
it("should render both GitHub buttons when GitHub token is set and repository is selected", () => { it("should render both GitHub buttons when GitHub token is set and repository is selected", async () => {
render(<ActionSuggestions onSuggestionsClick={() => {}} />); renderActionSuggestions();
// Find all buttons with data-testid="suggestion" // Find all buttons with data-testid="suggestion"
const buttons = screen.getAllByTestId("suggestion"); const buttons = await screen.findAllByTestId("suggestion");
// Check if we have at least 2 buttons // Check if we have at least 2 buttons
expect(buttons.length).toBeGreaterThanOrEqual(2); expect(buttons.length).toBeGreaterThanOrEqual(2);
@@ -69,30 +86,24 @@ describe("ActionSuggestions", () => {
}); });
it("should not render buttons when GitHub token is not set", () => { it("should not render buttons when GitHub token is not set", () => {
(useAuth as any).mockReturnValue({ renderActionSuggestions();
providersAreSet: false,
});
render(<ActionSuggestions onSuggestionsClick={() => {}} />);
expect(screen.queryByTestId("suggestion")).not.toBeInTheDocument(); expect(screen.queryByTestId("suggestion")).not.toBeInTheDocument();
}); });
it("should not render buttons when no repository is selected", () => { it("should not render buttons when no repository is selected", () => {
(useSelector as any).mockReturnValue({ useSelectorMock.mockReturnValue({
selectedRepository: null, selectedRepository: null,
}); });
render(<ActionSuggestions onSuggestionsClick={() => {}} />); renderActionSuggestions();
expect(screen.queryByTestId("suggestion")).not.toBeInTheDocument(); expect(screen.queryByTestId("suggestion")).not.toBeInTheDocument();
}); });
it("should have different prompts for 'Push to Branch' and 'Push & Create PR' buttons", () => { it("should have different prompts for 'Push to Branch' and 'Push & Create PR' buttons", () => {
// This test verifies that the prompts are different in the component // This test verifies that the prompts are different in the component
const component = render( renderActionSuggestions();
<ActionSuggestions onSuggestionsClick={() => {}} />,
);
// Get the component instance to access the internal values // Get the component instance to access the internal values
const pushBranchPrompt = const pushBranchPrompt =

View File

@@ -4,7 +4,6 @@ import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal"; import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
import OpenHands from "#/api/open-hands"; import OpenHands from "#/api/open-hands";
import { AuthProvider } from "#/context/auth-context";
describe("AnalyticsConsentFormModal", () => { describe("AnalyticsConsentFormModal", () => {
it("should call saveUserSettings with consent", async () => { it("should call saveUserSettings with consent", async () => {
@@ -14,11 +13,9 @@ describe("AnalyticsConsentFormModal", () => {
render(<AnalyticsConsentFormModal onClose={onCloseMock} />, { render(<AnalyticsConsentFormModal onClose={onCloseMock} />, {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<AuthProvider> <QueryClientProvider client={new QueryClient()}>
<QueryClientProvider client={new QueryClient()}> {children}
{children} </QueryClientProvider>
</QueryClientProvider>
</AuthProvider>
), ),
}); });

View File

@@ -2,22 +2,15 @@ import { render, screen } from "@testing-library/react";
import { it, describe, expect, vi, beforeEach, afterEach } from "vitest"; import { it, describe, expect, vi, beforeEach, afterEach } from "vitest";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { AuthModal } from "#/components/features/waitlist/auth-modal"; import { AuthModal } from "#/components/features/waitlist/auth-modal";
import * as AuthHook from "#/context/auth-context";
// Mock the useAuthUrl hook // Mock the useAuthUrl hook
vi.mock("#/hooks/use-auth-url", () => ({ vi.mock("#/hooks/use-auth-url", () => ({
useAuthUrl: () => "https://gitlab.com/oauth/authorize" useAuthUrl: () => "https://gitlab.com/oauth/authorize",
})); }));
describe("AuthModal", () => { describe("AuthModal", () => {
beforeEach(() => { beforeEach(() => {
vi.stubGlobal("location", { href: "" }); vi.stubGlobal("location", { href: "" });
vi.spyOn(AuthHook, "useAuth").mockReturnValue({
providersAreSet: false,
setProvidersAreSet: vi.fn(),
providerTokensSet: [],
setProviderTokensSet: vi.fn()
});
}); });
afterEach(() => { afterEach(() => {
@@ -28,8 +21,12 @@ describe("AuthModal", () => {
it("should render the GitHub and GitLab buttons", () => { it("should render the GitHub and GitLab buttons", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />); render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
const githubButton = screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" }); const githubButton = screen.getByRole("button", {
const gitlabButton = screen.getByRole("button", { name: "GITLAB$CONNECT_TO_GITLAB" }); name: "GITHUB$CONNECT_TO_GITHUB",
});
const gitlabButton = screen.getByRole("button", {
name: "GITLAB$CONNECT_TO_GITLAB",
});
expect(githubButton).toBeInTheDocument(); expect(githubButton).toBeInTheDocument();
expect(gitlabButton).toBeInTheDocument(); expect(gitlabButton).toBeInTheDocument();
@@ -40,7 +37,9 @@ describe("AuthModal", () => {
const mockUrl = "https://github.com/login/oauth/authorize"; const mockUrl = "https://github.com/login/oauth/authorize";
render(<AuthModal githubAuthUrl={mockUrl} appMode="saas" />); render(<AuthModal githubAuthUrl={mockUrl} appMode="saas" />);
const githubButton = screen.getByRole("button", { name: "GITHUB$CONNECT_TO_GITHUB" }); const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
});
await user.click(githubButton); await user.click(githubButton);
expect(window.location.href).toBe(mockUrl); expect(window.location.href).toBe(mockUrl);

View File

@@ -1,19 +1,13 @@
import { screen, waitFor, within } from "@testing-library/react"; import { screen, waitFor, within } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { import { QueryClientConfig } from "@tanstack/react-query";
QueryClientProvider,
QueryClient,
QueryClientConfig,
} from "@tanstack/react-query";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { createRoutesStub } from "react-router"; import { createRoutesStub } from "react-router";
import React from "react"; import React from "react";
import { renderWithProviders } from "test-utils";
import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel"; import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
import OpenHands from "#/api/open-hands"; import OpenHands from "#/api/open-hands";
import { AuthProvider } from "#/context/auth-context";
import { clickOnEditButton } from "./utils"; import { clickOnEditButton } from "./utils";
import { queryClientConfig } from "#/query-client-config";
import { renderWithProviders } from "test-utils";
describe("ConversationPanel", () => { describe("ConversationPanel", () => {
const onCloseMock = vi.fn(); const onCloseMock = vi.fn();
@@ -29,9 +23,9 @@ describe("ConversationPanel", () => {
preloadedState: { preloadedState: {
metrics: { metrics: {
cost: null, cost: null,
usage: null usage: null,
} },
} },
}); });
beforeAll(() => { beforeAll(() => {
@@ -75,7 +69,9 @@ describe("ConversationPanel", () => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.restoreAllMocks(); vi.restoreAllMocks();
// Setup default mock for getUserConversations // Setup default mock for getUserConversations
vi.spyOn(OpenHands, "getUserConversations").mockResolvedValue([...mockConversations]); vi.spyOn(OpenHands, "getUserConversations").mockResolvedValue([
...mockConversations,
]);
}); });
it("should render the conversations", async () => { it("should render the conversations", async () => {
@@ -129,7 +125,9 @@ describe("ConversationPanel", () => {
const cancelButton = screen.getByRole("button", { name: /cancel/i }); const cancelButton = screen.getByRole("button", { name: /cancel/i });
await user.click(cancelButton); await user.click(cancelButton);
expect(screen.queryByRole("button", { name: /cancel/i })).not.toBeInTheDocument(); expect(
screen.queryByRole("button", { name: /cancel/i }),
).not.toBeInTheDocument();
// Ensure the conversation is not deleted // Ensure the conversation is not deleted
cards = await screen.findAllByTestId("conversation-card"); cards = await screen.findAllByTestId("conversation-card");
@@ -168,9 +166,12 @@ describe("ConversationPanel", () => {
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations"); const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
getUserConversationsSpy.mockImplementation(async () => mockData); getUserConversationsSpy.mockImplementation(async () => mockData);
const deleteUserConversationSpy = vi.spyOn(OpenHands, "deleteUserConversation"); const deleteUserConversationSpy = vi.spyOn(
OpenHands,
"deleteUserConversation",
);
deleteUserConversationSpy.mockImplementation(async (id: string) => { deleteUserConversationSpy.mockImplementation(async (id: string) => {
const index = mockData.findIndex(conv => conv.conversation_id === id); const index = mockData.findIndex((conv) => conv.conversation_id === id);
if (index !== -1) { if (index !== -1) {
mockData.splice(index, 1); mockData.splice(index, 1);
} }
@@ -178,7 +179,7 @@ describe("ConversationPanel", () => {
renderConversationPanel(); renderConversationPanel();
let cards = await screen.findAllByTestId("conversation-card"); const cards = await screen.findAllByTestId("conversation-card");
expect(cards).toHaveLength(3); expect(cards).toHaveLength(3);
const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button"); const ellipsisButton = within(cards[0]).getByTestId("ellipsis-button");
@@ -192,7 +193,9 @@ describe("ConversationPanel", () => {
const confirmButton = screen.getByRole("button", { name: /confirm/i }); const confirmButton = screen.getByRole("button", { name: /confirm/i });
await user.click(confirmButton); await user.click(confirmButton);
expect(screen.queryByRole("button", { name: /confirm/i })).not.toBeInTheDocument(); expect(
screen.queryByRole("button", { name: /confirm/i }),
).not.toBeInTheDocument();
// Wait for the cards to update // Wait for the cards to update
await waitFor(() => { await waitFor(() => {
@@ -298,9 +301,9 @@ describe("ConversationPanel", () => {
preloadedState: { preloadedState: {
metrics: { metrics: {
cost: null, cost: null,
usage: null usage: null,
} },
} },
}); });
const toggleButton = screen.getByText("Toggle"); const toggleButton = screen.getByText("Toggle");

View File

@@ -5,7 +5,6 @@ import { createRoutesStub } from "react-router";
import { setupStore } from "test-utils"; import { setupStore } from "test-utils";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { AuthProvider } from "#/context/auth-context";
import { HomeHeader } from "#/components/features/home/home-header"; import { HomeHeader } from "#/components/features/home/home-header";
import OpenHands from "#/api/open-hands"; import OpenHands from "#/api/open-hands";
@@ -24,11 +23,9 @@ const renderHomeHeader = () => {
return render(<RouterStub />, { return render(<RouterStub />, {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<Provider store={setupStore()}> <Provider store={setupStore()}>
<AuthProvider initialProvidersAreSet> <QueryClientProvider client={new QueryClient()}>
<QueryClientProvider client={new QueryClient()}> {children}
{children} </QueryClientProvider>
</QueryClientProvider>
</AuthProvider>
</Provider> </Provider>
), ),
}); });

View File

@@ -1,16 +1,16 @@
import { render, screen, waitFor, within } from "@testing-library/react"; import { render, screen, waitFor, within } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { setupStore } from "test-utils"; import { setupStore } from "test-utils";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import { createRoutesStub, Outlet } from "react-router"; import { createRoutesStub, Outlet } from "react-router";
import OpenHands from "#/api/open-hands"; import OpenHands from "#/api/open-hands";
import { AuthProvider } from "#/context/auth-context";
import { GitRepository } from "#/types/git"; import { GitRepository } from "#/types/git";
import { RepoConnector } from "#/components/features/home/repo-connector"; import { RepoConnector } from "#/components/features/home/repo-connector";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
const renderRepoConnector = (initialProvidersAreSet = true) => { const renderRepoConnector = () => {
const mockRepoSelection = vi.fn(); const mockRepoSelection = vi.fn();
const RouterStub = createRoutesStub([ const RouterStub = createRoutesStub([
{ {
@@ -40,11 +40,9 @@ const renderRepoConnector = (initialProvidersAreSet = true) => {
return render(<RouterStub />, { return render(<RouterStub />, {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<Provider store={setupStore()}> <Provider store={setupStore()}>
<AuthProvider initialProvidersAreSet={initialProvidersAreSet}> <QueryClientProvider client={new QueryClient()}>
<QueryClientProvider client={new QueryClient()}> {children}
{children} </QueryClientProvider>
</QueryClientProvider>
</AuthProvider>
</Provider> </Provider>
), ),
}); });
@@ -65,6 +63,17 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
}, },
]; ];
beforeEach(() => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: "some-token",
gitlab: null,
},
});
});
describe("RepoConnector", () => { describe("RepoConnector", () => {
it("should render the repository connector section", () => { it("should render the repository connector section", () => {
renderRepoConnector(); renderRepoConnector();
@@ -99,7 +108,7 @@ describe("RepoConnector", () => {
renderRepoConnector(); renderRepoConnector();
const launchButton = screen.getByTestId("repo-launch-button"); const launchButton = await screen.findByTestId("repo-launch-button");
expect(launchButton).toBeDisabled(); expect(launchButton).toBeDisabled();
// Wait for the loading state to be replaced with the dropdown // Wait for the loading state to be replaced with the dropdown
@@ -147,7 +156,7 @@ describe("RepoConnector", () => {
const repoConnector = screen.getByTestId("repo-connector"); const repoConnector = screen.getByTestId("repo-connector");
const launchButton = const launchButton =
within(repoConnector).getByTestId("repo-launch-button"); await within(repoConnector).findByTestId("repo-launch-button");
await userEvent.click(launchButton); await userEvent.click(launchButton);
// repo not selected yet // repo not selected yet
@@ -184,7 +193,7 @@ describe("RepoConnector", () => {
renderRepoConnector(); renderRepoConnector();
const launchButton = screen.getByTestId("repo-launch-button"); const launchButton = await screen.findByTestId("repo-launch-button");
// Wait for the loading state to be replaced with the dropdown // Wait for the loading state to be replaced with the dropdown
const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown")); const dropdown = await waitFor(() => screen.getByTestId("repo-dropdown"));
@@ -197,14 +206,22 @@ describe("RepoConnector", () => {
}); });
it("should not display a button to settings if the user is signed in with their git provider", async () => { it("should not display a button to settings if the user is signed in with their git provider", async () => {
renderRepoConnector(true); renderRepoConnector();
expect(
screen.queryByTestId("navigate-to-settings-button"), await waitFor(() => {
).not.toBeInTheDocument(); expect(
screen.queryByTestId("navigate-to-settings-button"),
).not.toBeInTheDocument();
});
}); });
it("should display a button to settings if the user needs to sign in with their git provider", async () => { it("should display a button to settings if the user needs to sign in with their git provider", async () => {
renderRepoConnector(false); const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {},
});
renderRepoConnector();
const goToSettingsButton = await screen.findByTestId( const goToSettingsButton = await screen.findByTestId(
"navigate-to-settings-button", "navigate-to-settings-button",

View File

@@ -7,7 +7,6 @@ import { createRoutesStub } from "react-router";
import { setupStore } from "test-utils"; import { setupStore } from "test-utils";
import { SuggestedTask } from "#/components/features/home/tasks/task.types"; import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import OpenHands from "#/api/open-hands"; import OpenHands from "#/api/open-hands";
import { AuthProvider } from "#/context/auth-context";
import { TaskCard } from "#/components/features/home/tasks/task-card"; import { TaskCard } from "#/components/features/home/tasks/task-card";
import { GitRepository } from "#/types/git"; import { GitRepository } from "#/types/git";
@@ -41,11 +40,9 @@ const renderTaskCard = (task = MOCK_TASK_1) => {
return render(<RouterStub />, { return render(<RouterStub />, {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<Provider store={setupStore()}> <Provider store={setupStore()}>
<AuthProvider initialProvidersAreSet> <QueryClientProvider client={new QueryClient()}>
<QueryClientProvider client={new QueryClient()}> {children}
{children} </QueryClientProvider>
</QueryClientProvider>
</AuthProvider>
</Provider> </Provider>
), ),
}); });

View File

@@ -7,9 +7,8 @@ import { setupStore } from "test-utils";
import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions"; import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions";
import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api"; import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api";
import { MOCK_TASKS } from "#/mocks/task-suggestions-handlers"; import { MOCK_TASKS } from "#/mocks/task-suggestions-handlers";
import { AuthProvider } from "#/context/auth-context";
const renderTaskSuggestions = (initialProvidersAreSet = true) => { const renderTaskSuggestions = () => {
const RouterStub = createRoutesStub([ const RouterStub = createRoutesStub([
{ {
Component: TaskSuggestions, Component: TaskSuggestions,
@@ -28,11 +27,9 @@ const renderTaskSuggestions = (initialProvidersAreSet = true) => {
return render(<RouterStub />, { return render(<RouterStub />, {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<Provider store={setupStore()}> <Provider store={setupStore()}>
<AuthProvider initialProvidersAreSet={initialProvidersAreSet}> <QueryClientProvider client={new QueryClient()}>
<QueryClientProvider client={new QueryClient()}> {children}
{children} </QueryClientProvider>
</QueryClientProvider>
</AuthProvider>
</Provider> </Provider>
), ),
}); });
@@ -85,7 +82,7 @@ describe("TaskSuggestions", () => {
getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS); getSuggestedTasksSpy.mockResolvedValue(MOCK_TASKS);
renderTaskSuggestions(); renderTaskSuggestions();
const skeletons = screen.getAllByTestId("task-group-skeleton"); const skeletons = await screen.findAllByTestId("task-group-skeleton");
expect(skeletons.length).toBeGreaterThan(0); expect(skeletons.length).toBeGreaterThan(0);
await waitFor(() => { await waitFor(() => {

View File

@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils"; import { renderWithProviders } from "test-utils";
import { createRoutesStub } from "react-router"; import { createRoutesStub } from "react-router";
import { waitFor } from "@testing-library/react";
import { Sidebar } from "#/components/features/sidebar/sidebar"; import { Sidebar } from "#/components/features/sidebar/sidebar";
import OpenHands from "#/api/open-hands"; import OpenHands from "#/api/open-hands";
@@ -24,8 +25,8 @@ describe("Sidebar", () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("should fetch settings data on mount", () => { it("should fetch settings data on mount", async () => {
renderSidebar(); renderSidebar();
expect(getSettingsSpy).toHaveBeenCalled(); await waitFor(() => expect(getSettingsSpy).toHaveBeenCalled());
}); });
}); });

View File

@@ -8,7 +8,6 @@ import {
WsClientProvider, WsClientProvider,
useWsClient, useWsClient,
} from "#/context/ws-client-provider"; } from "#/context/ws-client-provider";
import { AuthProvider } from "#/context/auth-context";
describe("Propagate error message", () => { describe("Propagate error message", () => {
it("should do nothing when no message was passed from server", () => { it("should do nothing when no message was passed from server", () => {
@@ -91,11 +90,9 @@ describe("WsClientProvider", () => {
const { getByText } = render(<TestComponent />, { const { getByText } = render(<TestComponent />, {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}> <QueryClientProvider client={new QueryClient()}>
<AuthProvider initialProviderTokens={[]}> <WsClientProvider conversationId="test-conversation-id">
<WsClientProvider conversationId="test-conversation-id"> {children}
{children} </WsClientProvider>
</WsClientProvider>
</AuthProvider>
</QueryClientProvider> </QueryClientProvider>
), ),
}); });

View File

@@ -3,18 +3,15 @@ import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands"; import OpenHands from "#/api/open-hands";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings"; import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { AuthProvider } from "#/context/auth-context";
describe("useSaveSettings", () => { describe("useSaveSettings", () => {
it("should send an empty string for llm_api_key if an empty string is passed, otherwise undefined", async () => { it("should send an empty string for llm_api_key if an empty string is passed, otherwise undefined", async () => {
const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings"); const saveSettingsSpy = vi.spyOn(OpenHands, "saveSettings");
const { result } = renderHook(() => useSaveSettings(), { const { result } = renderHook(() => useSaveSettings(), {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<AuthProvider> <QueryClientProvider client={new QueryClient()}>
<QueryClientProvider client={new QueryClient()}> {children}
{children} </QueryClientProvider>
</QueryClientProvider>
</AuthProvider>
), ),
}); });

View File

@@ -1,30 +0,0 @@
import { createRoutesStub } from "react-router";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import { screen, waitFor } from "@testing-library/react";
import App from "#/routes/conversation";
import OpenHands from "#/api/open-hands";
import * as CustomToast from "#/utils/custom-toast-handlers";
describe("App", () => {
const errorToastSpy = vi.spyOn(CustomToast, "displayErrorToast");
const RouteStub = createRoutesStub([
{ Component: App, path: "/conversation/:conversationId" },
]);
beforeAll(() => {
vi.mock("#/hooks/use-terminal", () => ({
useTerminal: vi.fn(),
}));
});
afterEach(() => {
vi.clearAllMocks();
});
it("should render", async () => {
renderWithProviders(<RouteStub initialEntries={["/conversation/123"]} />);
await screen.findByTestId("app-route");
});
});

View File

@@ -5,7 +5,6 @@ import userEvent from "@testing-library/user-event";
import AppSettingsScreen from "#/routes/app-settings"; import AppSettingsScreen from "#/routes/app-settings";
import OpenHands from "#/api/open-hands"; import OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { AuthProvider } from "#/context/auth-context";
import { AvailableLanguages } from "#/i18n"; import { AvailableLanguages } from "#/i18n";
import * as CaptureConsent from "#/utils/handle-capture-consent"; import * as CaptureConsent from "#/utils/handle-capture-consent";
import * as ToastHandlers from "#/utils/custom-toast-handlers"; import * as ToastHandlers from "#/utils/custom-toast-handlers";
@@ -14,7 +13,7 @@ const renderAppSettingsScreen = () =>
render(<AppSettingsScreen />, { render(<AppSettingsScreen />, {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}> <QueryClientProvider client={new QueryClient()}>
<AuthProvider>{children}</AuthProvider> {children}
</QueryClientProvider> </QueryClientProvider>
), ),
}); });

View File

@@ -6,7 +6,6 @@ import userEvent from "@testing-library/user-event";
import GitSettingsScreen from "#/routes/git-settings"; import GitSettingsScreen from "#/routes/git-settings";
import OpenHands from "#/api/open-hands"; import OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers"; import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { AuthProvider } from "#/context/auth-context";
import { GetConfigResponse } from "#/api/open-hands.types"; import { GetConfigResponse } from "#/api/open-hands.types";
import * as ToastHandlers from "#/utils/custom-toast-handlers"; import * as ToastHandlers from "#/utils/custom-toast-handlers";
import { SecretsService } from "#/api/secrets-service"; import { SecretsService } from "#/api/secrets-service";
@@ -46,7 +45,7 @@ const renderGitSettingsScreen = () => {
{ {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider> {children}
</QueryClientProvider> </QueryClientProvider>
), ),
}, },
@@ -55,9 +54,7 @@ const renderGitSettingsScreen = () => {
const rerenderGitSettingsScreen = () => const rerenderGitSettingsScreen = () =>
rerender( rerender(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider> <GitSettingsRouterStub initialEntries={["/settings/github"]} />
<GitSettingsRouterStub initialEntries={["/settings/github"]} />
</AuthProvider>
</QueryClientProvider>, </QueryClientProvider>,
); );
@@ -141,8 +138,8 @@ describe("Content", () => {
getSettingsSpy.mockResolvedValue({ getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS, ...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: { provider_tokens_set: {
github: null, github: "some-token",
gitlab: null, gitlab: "some-token",
}, },
}); });
queryClient.invalidateQueries(); queryClient.invalidateQueries();
@@ -166,7 +163,7 @@ describe("Content", () => {
getSettingsSpy.mockResolvedValue({ getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS, ...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: { provider_tokens_set: {
gitlab: null, gitlab: "some-token",
}, },
}); });
queryClient.invalidateQueries(); queryClient.invalidateQueries();
@@ -293,6 +290,7 @@ describe("Form submission", () => {
...MOCK_DEFAULT_USER_SETTINGS, ...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: { provider_tokens_set: {
github: null, github: null,
gitlab: "some-token",
}, },
}); });
@@ -323,6 +321,7 @@ describe("Form submission", () => {
...MOCK_DEFAULT_USER_SETTINGS, ...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: { provider_tokens_set: {
github: null, github: null,
gitlab: "some-token",
}, },
}); });

View File

@@ -6,10 +6,10 @@ import { createRoutesStub } from "react-router";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import { createAxiosNotFoundErrorObject, setupStore } from "test-utils"; import { createAxiosNotFoundErrorObject, setupStore } from "test-utils";
import HomeScreen from "#/routes/home"; import HomeScreen from "#/routes/home";
import { AuthProvider } from "#/context/auth-context";
import { GitRepository } from "#/types/git"; import { GitRepository } from "#/types/git";
import OpenHands from "#/api/open-hands"; import OpenHands from "#/api/open-hands";
import MainApp from "#/routes/root-layout"; import MainApp from "#/routes/root-layout";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
const RouterStub = createRoutesStub([ const RouterStub = createRoutesStub([
{ {
@@ -32,15 +32,13 @@ const RouterStub = createRoutesStub([
}, },
]); ]);
const renderHomeScreen = (initialProvidersAreSet = true) => const renderHomeScreen = () =>
render(<RouterStub />, { render(<RouterStub />, {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<Provider store={setupStore()}> <Provider store={setupStore()}>
<AuthProvider initialProvidersAreSet={initialProvidersAreSet}> <QueryClientProvider client={new QueryClient()}>
<QueryClientProvider client={new QueryClient()}> {children}
{children} </QueryClientProvider>
</QueryClientProvider>
</AuthProvider>
</Provider> </Provider>
), ),
}); });
@@ -61,6 +59,17 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
]; ];
describe("HomeScreen", () => { describe("HomeScreen", () => {
beforeEach(() => {
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {
github: "some-token",
gitlab: null,
},
});
});
it("should render", () => { it("should render", () => {
renderHomeScreen(); renderHomeScreen();
screen.getByTestId("home-screen"); screen.getByTestId("home-screen");
@@ -69,8 +78,10 @@ describe("HomeScreen", () => {
it("should render the repository connector and suggested tasks sections", async () => { it("should render the repository connector and suggested tasks sections", async () => {
renderHomeScreen(); renderHomeScreen();
screen.getByTestId("repo-connector"); await waitFor(() => {
screen.getByTestId("task-suggestions"); screen.getByTestId("repo-connector");
screen.getByTestId("task-suggestions");
});
}); });
it("should have responsive layout for mobile and desktop screens", async () => { it("should have responsive layout for mobile and desktop screens", async () => {
@@ -91,7 +102,7 @@ describe("HomeScreen", () => {
renderHomeScreen(); renderHomeScreen();
const taskSuggestions = screen.getByTestId("task-suggestions"); const taskSuggestions = await screen.findByTestId("task-suggestions");
// Initially, all tasks should be visible // Initially, all tasks should be visible
await waitFor(() => { await waitFor(() => {
@@ -126,7 +137,7 @@ describe("HomeScreen", () => {
renderHomeScreen(); renderHomeScreen();
const taskSuggestions = screen.getByTestId("task-suggestions"); const taskSuggestions = await screen.findByTestId("task-suggestions");
// Initially, all tasks should be visible // Initially, all tasks should be visible
await waitFor(() => { await waitFor(() => {
@@ -164,7 +175,7 @@ describe("HomeScreen", () => {
describe("launch buttons", () => { describe("launch buttons", () => {
const setupLaunchButtons = async () => { const setupLaunchButtons = async () => {
let headerLaunchButton = screen.getByTestId("header-launch-button"); let headerLaunchButton = screen.getByTestId("header-launch-button");
let repoLaunchButton = screen.getByTestId("repo-launch-button"); let repoLaunchButton = await screen.findByTestId("repo-launch-button");
let tasksLaunchButtons = let tasksLaunchButtons =
await screen.findAllByTestId("task-launch-button"); await screen.findAllByTestId("task-launch-button");
@@ -256,7 +267,7 @@ describe("HomeScreen", () => {
}); });
it("should hide the suggested tasks section if not authed with git(hub|lab)", async () => { it("should hide the suggested tasks section if not authed with git(hub|lab)", async () => {
renderHomeScreen(false); renderHomeScreen();
const taskSuggestions = screen.queryByTestId("task-suggestions"); const taskSuggestions = screen.queryByTestId("task-suggestions");
const repoConnector = screen.getByTestId("repo-connector"); const repoConnector = screen.getByTestId("repo-connector");
@@ -267,6 +278,10 @@ describe("HomeScreen", () => {
}); });
describe("Settings 404", () => { describe("Settings 404", () => {
beforeEach(() => {
vi.resetAllMocks();
});
const getConfigSpy = vi.spyOn(OpenHands, "getConfig"); const getConfigSpy = vi.spyOn(OpenHands, "getConfig");
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings"); const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");

View File

@@ -8,7 +8,6 @@ import {
MOCK_DEFAULT_USER_SETTINGS, MOCK_DEFAULT_USER_SETTINGS,
resetTestHandlersMockSettings, resetTestHandlersMockSettings,
} from "#/mocks/handlers"; } from "#/mocks/handlers";
import { AuthProvider } from "#/context/auth-context";
import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set"; import * as AdvancedSettingsUtlls from "#/utils/has-advanced-settings-set";
import * as ToastHandlers from "#/utils/custom-toast-handlers"; import * as ToastHandlers from "#/utils/custom-toast-handlers";
@@ -16,7 +15,7 @@ const renderLlmSettingsScreen = () =>
render(<LlmSettingsScreen />, { render(<LlmSettingsScreen />, {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}> <QueryClientProvider client={new QueryClient()}>
<AuthProvider>{children}</AuthProvider> {children}
</QueryClientProvider> </QueryClientProvider>
), ),
}); });

View File

@@ -2,24 +2,24 @@ import { render, screen, within } from "@testing-library/react";
import { createRoutesStub } from "react-router"; import { createRoutesStub } from "react-router";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AuthProvider } from "#/context/auth-context";
import SettingsScreen from "#/routes/settings"; import SettingsScreen from "#/routes/settings";
import OpenHands from "#/api/open-hands"; import OpenHands from "#/api/open-hands";
// Mock the i18next hook // Mock the i18next hook
vi.mock("react-i18next", async () => { vi.mock("react-i18next", async () => {
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next"); const actual =
await vi.importActual<typeof import("react-i18next")>("react-i18next");
return { return {
...actual, ...actual,
useTranslation: () => ({ useTranslation: () => ({
t: (key: string) => { t: (key: string) => {
const translations: Record<string, string> = { const translations: Record<string, string> = {
"SETTINGS$NAV_GIT": "Git", SETTINGS$NAV_GIT: "Git",
"SETTINGS$NAV_APPLICATION": "Application", SETTINGS$NAV_APPLICATION: "Application",
"SETTINGS$NAV_CREDITS": "Credits", SETTINGS$NAV_CREDITS: "Credits",
"SETTINGS$NAV_API_KEYS": "API Keys", SETTINGS$NAV_API_KEYS: "API Keys",
"SETTINGS$NAV_LLM": "LLM", SETTINGS$NAV_LLM: "LLM",
"SETTINGS$TITLE": "Settings" SETTINGS$TITLE: "Settings",
}; };
return translations[key] || key; return translations[key] || key;
}, },
@@ -71,11 +71,9 @@ describe("Settings Screen", () => {
const queryClient = new QueryClient(); const queryClient = new QueryClient();
return render(<RouterStub initialEntries={[path]} />, { return render(<RouterStub initialEntries={[path]} />, {
wrapper: ({ children }) => ( wrapper: ({ children }) => (
<AuthProvider> <QueryClientProvider client={queryClient}>
<QueryClientProvider client={queryClient}> {children}
{children} </QueryClientProvider>
</QueryClientProvider>
</AuthProvider>
), ),
}); });
}; };

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { Provider } from "#/types/settings";
import { convertRawProvidersToList } from "#/utils/convert-raw-providers-to-list";
describe("convertRawProvidersToList", () => {
it("should convert raw provider tokens to a list of providers", () => {
const example1: Partial<Record<Provider, string | null>> | undefined = {
github: "test-token",
gitlab: "test-token",
};
const example2: Partial<Record<Provider, string | null>> | undefined = {
github: "",
};
const example3: Partial<Record<Provider, string | null>> | undefined = {
gitlab: null,
};
expect(convertRawProvidersToList(example1)).toEqual(["github", "gitlab"]);
expect(convertRawProvidersToList(example2)).toEqual(["github"]);
expect(convertRawProvidersToList(example3)).toEqual(["gitlab"]);
});
});

View File

@@ -4,8 +4,8 @@ import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item"; import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
import type { RootState } from "#/store"; import type { RootState } from "#/store";
import { useAuth } from "#/context/auth-context";
import { I18nKey } from "#/i18n/declaration"; import { I18nKey } from "#/i18n/declaration";
import { useUserProviders } from "#/hooks/use-user-providers";
interface ActionSuggestionsProps { interface ActionSuggestionsProps {
onSuggestionsClick: (value: string) => void; onSuggestionsClick: (value: string) => void;
@@ -15,13 +15,14 @@ export function ActionSuggestions({
onSuggestionsClick, onSuggestionsClick,
}: ActionSuggestionsProps) { }: ActionSuggestionsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { providersAreSet } = useAuth(); const { providers } = useUserProviders();
const { selectedRepository } = useSelector( const { selectedRepository } = useSelector(
(state: RootState) => state.initialQuery, (state: RootState) => state.initialQuery,
); );
const [hasPullRequest, setHasPullRequest] = React.useState(false); const [hasPullRequest, setHasPullRequest] = React.useState(false);
const providersAreSet = providers.length > 0;
const isGitLab = const isGitLab =
selectedRepository !== null && selectedRepository !== null &&
selectedRepository.git_provider && selectedRepository.git_provider &&

View File

@@ -1,20 +1,21 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ConnectToProviderMessage } from "./connect-to-provider-message"; import { ConnectToProviderMessage } from "./connect-to-provider-message";
import { useAuth } from "#/context/auth-context";
import { RepositorySelectionForm } from "./repo-selection-form"; import { RepositorySelectionForm } from "./repo-selection-form";
import { useConfig } from "#/hooks/query/use-config"; import { useConfig } from "#/hooks/query/use-config";
import { RepoProviderLinks } from "./repo-provider-links"; import { RepoProviderLinks } from "./repo-provider-links";
import { useUserProviders } from "#/hooks/use-user-providers";
interface RepoConnectorProps { interface RepoConnectorProps {
onRepoSelection: (repoTitle: string | null) => void; onRepoSelection: (repoTitle: string | null) => void;
} }
export function RepoConnector({ onRepoSelection }: RepoConnectorProps) { export function RepoConnector({ onRepoSelection }: RepoConnectorProps) {
const { providersAreSet } = useAuth(); const { providers } = useUserProviders();
const { data: config } = useConfig(); const { data: config } = useConfig();
const { t } = useTranslation(); const { t } = useTranslation();
const isSaaS = config?.APP_MODE === "saas"; const isSaaS = config?.APP_MODE === "saas";
const providersAreSet = providers.length > 0;
return ( return (
<section <section

View File

@@ -1,5 +1,4 @@
import React from "react"; import React from "react";
import posthog from "posthog-js";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { useGitUser } from "#/hooks/query/use-git-user"; import { useGitUser } from "#/hooks/query/use-git-user";
import { UserActions } from "./user-actions"; import { UserActions } from "./user-actions";
@@ -26,7 +25,7 @@ export function Sidebar() {
isError: settingsIsError, isError: settingsIsError,
isFetching: isFetchingSettings, isFetching: isFetchingSettings,
} = useSettings(); } = useSettings();
const { mutateAsync: logout } = useLogout(); const { mutate: logout } = useLogout();
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false); const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
@@ -62,11 +61,6 @@ export function Sidebar() {
location.pathname, location.pathname,
]); ]);
const handleLogout = async () => {
await logout();
posthog.reset();
};
return ( return (
<> <>
<aside className="h-[40px] md:h-auto px-1 flex flex-row md:flex-col gap-1"> <aside className="h-[40px] md:h-auto px-1 flex flex-row md:flex-col gap-1">
@@ -89,7 +83,7 @@ export function Sidebar() {
user={ user={
user.data ? { avatar_url: user.data.avatar_url } : undefined user.data ? { avatar_url: user.data.avatar_url } : undefined
} }
onLogout={handleLogout} onLogout={logout}
isLoading={user.isFetching} isLoading={user.isFetching}
/> />
</div> </div>

View File

@@ -1,52 +0,0 @@
import React from "react";
import { Provider } from "#/types/settings";
interface AuthContextType {
providerTokensSet: Provider[];
setProviderTokensSet: (tokens: Provider[]) => void;
providersAreSet: boolean;
setProvidersAreSet: (status: boolean) => void;
}
interface AuthContextProps extends React.PropsWithChildren {
initialProviderTokens?: Provider[];
initialProvidersAreSet?: boolean;
}
const AuthContext = React.createContext<AuthContextType | undefined>(undefined);
function AuthProvider({
children,
initialProviderTokens = [],
initialProvidersAreSet = false,
}: AuthContextProps) {
const [providerTokensSet, setProviderTokensSet] = React.useState<Provider[]>(
initialProviderTokens,
);
const [providersAreSet, setProvidersAreSet] = React.useState<boolean>(
initialProvidersAreSet,
);
const value = React.useMemo(
() => ({
providerTokensSet,
setProviderTokensSet,
providersAreSet,
setProvidersAreSet,
}),
[providerTokensSet],
);
return <AuthContext value={value}>{children}</AuthContext>;
}
function useAuth() {
const context = React.useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within a AuthProvider");
}
return context;
}
export { AuthProvider, useAuth };

View File

@@ -14,7 +14,7 @@ import {
UserMessageAction, UserMessageAction,
} from "#/types/core/actions"; } from "#/types/core/actions";
import { Conversation } from "#/api/open-hands.types"; import { Conversation } from "#/api/open-hands.types";
import { useAuth } from "./auth-context"; import { useUserProviders } from "#/hooks/use-user-providers";
const isOpenHandsEvent = (event: unknown): event is OpenHandsParsedEvent => const isOpenHandsEvent = (event: unknown): event is OpenHandsParsedEvent =>
typeof event === "object" && typeof event === "object" &&
@@ -128,7 +128,7 @@ export function WsClientProvider({
); );
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]); const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
const lastEventRef = React.useRef<Record<string, unknown> | null>(null); const lastEventRef = React.useRef<Record<string, unknown> | null>(null);
const { providerTokensSet } = useAuth(); const { providers } = useUserProviders();
const messageRateHandler = useRate({ threshold: 250 }); const messageRateHandler = useRate({ threshold: 250 });
@@ -224,7 +224,7 @@ export function WsClientProvider({
const query = { const query = {
latest_event_id: lastEvent?.id ?? -1, latest_event_id: lastEvent?.id ?? -1,
conversation_id: conversationId, conversation_id: conversationId,
providers_set: providerTokensSet, providers_set: providers,
}; };
const baseUrl = const baseUrl =

View File

@@ -11,12 +11,11 @@ import { hydrateRoot } from "react-dom/client";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import posthog from "posthog-js"; import posthog from "posthog-js";
import "./i18n"; import "./i18n";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import store from "./store"; import store from "./store";
import { AuthProvider } from "./context/auth-context";
import { queryClientConfig } from "./query-client-config";
import OpenHands from "./api/open-hands"; import OpenHands from "./api/open-hands";
import { displayErrorToast } from "./utils/custom-toast-handlers"; import { displayErrorToast } from "./utils/custom-toast-handlers";
import { queryClient } from "./query-client-config";
function PosthogInit() { function PosthogInit() {
const [posthogClientKey, setPosthogClientKey] = React.useState<string | null>( const [posthogClientKey, setPosthogClientKey] = React.useState<string | null>(
@@ -59,20 +58,16 @@ async function prepareApp() {
} }
} }
export const queryClient = new QueryClient(queryClientConfig);
prepareApp().then(() => prepareApp().then(() =>
startTransition(() => { startTransition(() => {
hydrateRoot( hydrateRoot(
document, document,
<StrictMode> <StrictMode>
<Provider store={store}> <Provider store={store}>
<AuthProvider> <QueryClientProvider client={queryClient}>
<QueryClientProvider client={queryClient}> <HydratedRouter />
<HydratedRouter /> <PosthogInit />
<PosthogInit /> </QueryClientProvider>
</QueryClientProvider>
</AuthProvider>
</Provider> </Provider>
</StrictMode>, </StrictMode>,
); );

View File

@@ -1,38 +1,23 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import posthog from "posthog-js";
import OpenHands from "#/api/open-hands"; import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { useConfig } from "../query/use-config"; import { useConfig } from "../query/use-config";
export const useLogout = () => { export const useLogout = () => {
const { setProviderTokensSet, setProvidersAreSet } = useAuth();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: config } = useConfig(); const { data: config } = useConfig();
const navigate = useNavigate(); const navigate = useNavigate();
return useMutation({ return useMutation({
mutationFn: async () => { mutationFn: () => OpenHands.logout(config?.APP_MODE ?? "oss"),
// Pause all queries that depend on githubTokenIsSet onSuccess: async () => {
queryClient.setQueryData(["user"], null);
// Call logout endpoint
await OpenHands.logout(config?.APP_MODE ?? "oss");
// Remove settings from cache so it will be refetched with new token state
queryClient.removeQueries({ queryKey: ["settings"] });
// Update token state - this will trigger a settings refetch since it's part of the query key
setProviderTokensSet([]);
setProvidersAreSet(false);
// Navigate to root page and refresh the page
navigate("/");
window.location.reload();
},
onSuccess: () => {
// Home screen suggested tasks
queryClient.invalidateQueries({ queryKey: ["tasks"] });
queryClient.removeQueries({ queryKey: ["tasks"] }); queryClient.removeQueries({ queryKey: ["tasks"] });
queryClient.removeQueries({ queryKey: ["settings"] });
queryClient.removeQueries({ queryKey: ["user"] });
posthog.reset();
await navigate("/");
}, },
}); });
}; };

View File

@@ -1,17 +1,15 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import ApiKeysClient from "#/api/api-keys"; import ApiKeysClient from "#/api/api-keys";
import { useConfig } from "./use-config"; import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
export const API_KEYS_QUERY_KEY = "api-keys"; export const API_KEYS_QUERY_KEY = "api-keys";
export function useApiKeys() { export function useApiKeys() {
const { providersAreSet } = useAuth();
const { data: config } = useConfig(); const { data: config } = useConfig();
return useQuery({ return useQuery({
queryKey: [API_KEYS_QUERY_KEY], queryKey: [API_KEYS_QUERY_KEY],
enabled: providersAreSet && config?.APP_MODE === "saas", enabled: config?.APP_MODE === "saas",
queryFn: async () => { queryFn: async () => {
const keys = await ApiKeysClient.getApiKeys(); const keys = await ApiKeysClient.getApiKeys();
return Array.isArray(keys) ? keys : []; return Array.isArray(keys) ? keys : [];

View File

@@ -3,19 +3,16 @@ import React from "react";
import posthog from "posthog-js"; import posthog from "posthog-js";
import { useConfig } from "./use-config"; import { useConfig } from "./use-config";
import OpenHands from "#/api/open-hands"; import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context"; import { useUserProviders } from "../use-user-providers";
import { useLogout } from "../mutation/use-logout";
export const useGitUser = () => { export const useGitUser = () => {
const { providersAreSet, providerTokensSet } = useAuth(); const { providers } = useUserProviders();
const { mutateAsync: logout } = useLogout();
const { data: config } = useConfig(); const { data: config } = useConfig();
const user = useQuery({ const user = useQuery({
queryKey: ["user", providerTokensSet], queryKey: ["user"],
queryFn: OpenHands.getGitUser, queryFn: OpenHands.getGitUser,
enabled: providersAreSet && !!config?.APP_MODE, enabled: !!config?.APP_MODE && providers.length > 0,
retry: false, retry: false,
staleTime: 1000 * 60 * 5, // 5 minutes staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes gcTime: 1000 * 60 * 15, // 15 minutes
@@ -33,16 +30,5 @@ export const useGitUser = () => {
} }
}, [user.data]); }, [user.data]);
const handleLogout = async () => {
await logout();
posthog.reset();
};
React.useEffect(() => {
if (user.isError) {
handleLogout();
}
}, [user.isError]);
return user; return user;
}; };

View File

@@ -1,19 +1,16 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import React from "react";
import OpenHands from "#/api/open-hands"; import OpenHands from "#/api/open-hands";
import { useConfig } from "./use-config"; import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page"; import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
export const useIsAuthed = () => { export const useIsAuthed = () => {
const { providersAreSet } = useAuth();
const { data: config } = useConfig(); const { data: config } = useConfig();
const isOnTosPage = useIsOnTosPage(); const isOnTosPage = useIsOnTosPage();
const appMode = React.useMemo(() => config?.APP_MODE, [config]); const appMode = config?.APP_MODE;
return useQuery({ return useQuery({
queryKey: ["user", "authenticated", providersAreSet, appMode], queryKey: ["user", "authenticated", appMode],
queryFn: () => OpenHands.authenticate(appMode!), queryFn: () => OpenHands.authenticate(appMode!),
enabled: !!appMode && !isOnTosPage, enabled: !!appMode && !isOnTosPage,
staleTime: 1000 * 60 * 5, // 5 minutes staleTime: 1000 * 60 * 5, // 5 minutes

View File

@@ -2,10 +2,10 @@ import { useQuery } from "@tanstack/react-query";
import React from "react"; import React from "react";
import posthog from "posthog-js"; import posthog from "posthog-js";
import OpenHands from "#/api/open-hands"; import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { DEFAULT_SETTINGS } from "#/services/settings"; import { DEFAULT_SETTINGS } from "#/services/settings";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page"; import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { Settings } from "#/types/settings"; import { Settings } from "#/types/settings";
import { useIsAuthed } from "./use-is-authed";
const getSettingsQueryFn = async (): Promise<Settings> => { const getSettingsQueryFn = async (): Promise<Settings> => {
const apiSettings = await OpenHands.getSettings(); const apiSettings = await OpenHands.getSettings();
@@ -30,13 +30,11 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
}; };
export const useSettings = () => { export const useSettings = () => {
const { setProviderTokensSet, providerTokensSet, setProvidersAreSet } =
useAuth();
const isOnTosPage = useIsOnTosPage(); const isOnTosPage = useIsOnTosPage();
const { data: userIsAuthenticated } = useIsAuthed();
const query = useQuery({ const query = useQuery({
queryKey: ["settings", providerTokensSet], queryKey: ["settings"],
queryFn: getSettingsQueryFn, queryFn: getSettingsQueryFn,
// Only retry if the error is not a 404 because we // Only retry if the error is not a 404 because we
// would want to show the modal immediately if the // would want to show the modal immediately if the
@@ -44,7 +42,7 @@ export const useSettings = () => {
retry: (_, error) => error.status !== 404, retry: (_, error) => error.status !== 404,
staleTime: 1000 * 60 * 5, // 5 minutes staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes gcTime: 1000 * 60 * 15, // 15 minutes
enabled: !isOnTosPage, enabled: !isOnTosPage && !!userIsAuthenticated,
meta: { meta: {
disableToast: true, disableToast: true,
}, },
@@ -56,18 +54,6 @@ export const useSettings = () => {
} }
}, [query.data?.LLM_API_KEY_SET, query.isFetched]); }, [query.data?.LLM_API_KEY_SET, query.isFetched]);
React.useEffect(() => {
if (query.data?.PROVIDER_TOKENS_SET) {
const providers = query.data.PROVIDER_TOKENS_SET;
const setProviders = Object.keys(providers) as Array<
keyof typeof providers
>;
setProviderTokensSet(setProviders);
const atLeastOneSet = setProviders.length > 0;
setProvidersAreSet(atLeastOneSet);
}
}, [query.data?.PROVIDER_TOKENS_SET, query.isFetched]);
// We want to return the defaults if the settings aren't found so the user can still see the // We want to return the defaults if the settings aren't found so the user can still see the
// options to make their initial save. We don't set the defaults in `initialData` above because // options to make their initial save. We don't set the defaults in `initialData` above because
// that would prepopulate the data to the cache and mess with expectations. Read more: // that would prepopulate the data to the cache and mess with expectations. Read more:

View File

@@ -1,15 +1,15 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api"; import { SuggestionsService } from "#/api/suggestions-service/suggestions-service.api";
import { groupSuggestedTasks } from "#/utils/group-suggested-tasks"; import { groupSuggestedTasks } from "#/utils/group-suggested-tasks";
import { useAuth } from "#/context/auth-context"; import { useIsAuthed } from "./use-is-authed";
export const useSuggestedTasks = () => { export const useSuggestedTasks = () => {
const { providersAreSet } = useAuth(); const { data: userIsAuthenticated } = useIsAuthed();
return useQuery({ return useQuery({
queryKey: ["tasks"], queryKey: ["tasks"],
queryFn: SuggestionsService.getSuggestedTasks, queryFn: SuggestionsService.getSuggestedTasks,
select: groupSuggestedTasks, select: groupSuggestedTasks,
enabled: providersAreSet, enabled: !!userIsAuthenticated,
}); });
}; };

View File

@@ -1,15 +1,10 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAuth } from "#/context/auth-context";
import OpenHands from "#/api/open-hands"; import OpenHands from "#/api/open-hands";
export const useUserRepositories = () => { export const useUserRepositories = () =>
const { providerTokensSet, providersAreSet } = useAuth(); useQuery({
queryKey: ["repositories"],
return useQuery({
queryKey: ["repositories", providerTokensSet],
queryFn: OpenHands.retrieveUserGitRepositories, queryFn: OpenHands.retrieveUserGitRepositories,
enabled: providersAreSet,
staleTime: 1000 * 60 * 5, // 5 minutes staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes gcTime: 1000 * 60 * 15, // 15 minutes
}); });
};

View File

@@ -1,11 +0,0 @@
import { useLogout } from "./mutation/use-logout";
export const useAppLogout = () => {
const { mutateAsync: logout } = useLogout();
const handleLogout = async () => {
await logout();
};
return { handleLogout };
};

View File

@@ -1,7 +1,5 @@
import React from "react";
import { generateAuthUrl } from "#/utils/generate-auth-url"; import { generateAuthUrl } from "#/utils/generate-auth-url";
import { GetConfigResponse } from "#/api/open-hands.types"; import { GetConfigResponse } from "#/api/open-hands.types";
import { useAuth } from "#/context/auth-context";
interface UseAuthUrlConfig { interface UseAuthUrlConfig {
appMode: GetConfigResponse["APP_MODE"] | null; appMode: GetConfigResponse["APP_MODE"] | null;
@@ -9,21 +7,12 @@ interface UseAuthUrlConfig {
} }
export const useAuthUrl = (config: UseAuthUrlConfig) => { export const useAuthUrl = (config: UseAuthUrlConfig) => {
const { providersAreSet } = useAuth(); if (config.appMode === "saas") {
return generateAuthUrl(
config.identityProvider,
new URL(window.location.href),
);
}
return React.useMemo(() => { return null;
if (config.appMode === "saas" && !providersAreSet) {
try {
return generateAuthUrl(
config.identityProvider,
new URL(window.location.href),
);
} catch (e) {
// In test environment, window.location.href might not be a valid URL
return null;
}
}
return null;
}, [providersAreSet, config.appMode, config.identityProvider]);
}; };

View File

@@ -0,0 +1,9 @@
import { convertRawProvidersToList } from "#/utils/convert-raw-providers-to-list";
import { useSettings } from "./query/use-settings";
export const useUserProviders = () => {
const { data: settings } = useSettings();
return {
providers: convertRawProvidersToList(settings?.PROVIDER_TOKENS_SET),
};
};

View File

@@ -1,17 +1,26 @@
import { import { QueryCache, MutationCache, QueryClient } from "@tanstack/react-query";
QueryClientConfig,
QueryCache,
MutationCache,
} from "@tanstack/react-query";
import i18next from "i18next"; import i18next from "i18next";
import { AxiosError } from "axios";
import { I18nKey } from "./i18n/declaration"; import { I18nKey } from "./i18n/declaration";
import { retrieveAxiosErrorMessage } from "./utils/retrieve-axios-error-message"; import { retrieveAxiosErrorMessage } from "./utils/retrieve-axios-error-message";
import { displayErrorToast } from "./utils/custom-toast-handlers"; import { displayErrorToast } from "./utils/custom-toast-handlers";
const handle401Error = (error: AxiosError, queryClient: QueryClient) => {
if (error?.response?.status === 401 || error?.status === 401) {
queryClient.invalidateQueries({ queryKey: ["user", "authenticated"] });
}
};
const shownErrors = new Set<string>(); const shownErrors = new Set<string>();
export const queryClientConfig: QueryClientConfig = { export const queryClient = new QueryClient({
queryCache: new QueryCache({ queryCache: new QueryCache({
onError: (error, query) => { onError: (error, query) => {
const isAuthQuery =
query.queryKey[0] === "user" && query.queryKey[1] === "authenticated";
if (!isAuthQuery) {
handle401Error(error, queryClient);
}
if (!query.meta?.disableToast) { if (!query.meta?.disableToast) {
const errorMessage = retrieveAxiosErrorMessage(error); const errorMessage = retrieveAxiosErrorMessage(error);
@@ -28,10 +37,12 @@ export const queryClientConfig: QueryClientConfig = {
}), }),
mutationCache: new MutationCache({ mutationCache: new MutationCache({
onError: (error, _, __, mutation) => { onError: (error, _, __, mutation) => {
handle401Error(error, queryClient);
if (!mutation?.meta?.disableToast) { if (!mutation?.meta?.disableToast) {
const message = retrieveAxiosErrorMessage(error); const message = retrieveAxiosErrorMessage(error);
displayErrorToast(message || i18next.t(I18nKey.ERROR$GENERIC)); displayErrorToast(message || i18next.t(I18nKey.ERROR$GENERIC));
} }
}, },
}), }),
}; });

View File

@@ -14,8 +14,8 @@ import {
} from "#/utils/custom-toast-handlers"; } from "#/utils/custom-toast-handlers";
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message"; import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
import { GitSettingInputsSkeleton } from "#/components/features/settings/git-settings/github-settings-inputs-skeleton"; import { GitSettingInputsSkeleton } from "#/components/features/settings/git-settings/github-settings-inputs-skeleton";
import { useAuth } from "#/context/auth-context";
import { useAddGitProviders } from "#/hooks/mutation/use-add-git-providers"; import { useAddGitProviders } from "#/hooks/mutation/use-add-git-providers";
import { useUserProviders } from "#/hooks/use-user-providers";
function GitSettingsScreen() { function GitSettingsScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -23,7 +23,7 @@ function GitSettingsScreen() {
const { mutate: saveGitProviders, isPending } = useAddGitProviders(); const { mutate: saveGitProviders, isPending } = useAddGitProviders();
const { mutate: disconnectGitTokens } = useLogout(); const { mutate: disconnectGitTokens } = useLogout();
const { providerTokensSet } = useAuth(); const { providers } = useUserProviders();
const { isLoading } = useSettings(); const { isLoading } = useSettings();
const { data: config } = useConfig(); const { data: config } = useConfig();
@@ -33,8 +33,8 @@ function GitSettingsScreen() {
React.useState(false); React.useState(false);
const isSaas = config?.APP_MODE === "saas"; const isSaas = config?.APP_MODE === "saas";
const isGitHubTokenSet = providerTokensSet.includes("github"); const isGitHubTokenSet = providers.includes("github");
const isGitLabTokenSet = providerTokensSet.includes("gitlab"); const isGitLabTokenSet = providers.includes("gitlab");
const formAction = async (formData: FormData) => { const formAction = async (formData: FormData) => {
const disconnectButtonClicked = const disconnectButtonClicked =

View File

@@ -3,16 +3,18 @@ import { PrefetchPageLinks } from "react-router";
import { HomeHeader } from "#/components/features/home/home-header"; import { HomeHeader } from "#/components/features/home/home-header";
import { RepoConnector } from "#/components/features/home/repo-connector"; import { RepoConnector } from "#/components/features/home/repo-connector";
import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions"; import { TaskSuggestions } from "#/components/features/home/tasks/task-suggestions";
import { useAuth } from "#/context/auth-context"; import { useUserProviders } from "#/hooks/use-user-providers";
<PrefetchPageLinks page="/conversations/:conversationId" />; <PrefetchPageLinks page="/conversations/:conversationId" />;
function HomeScreen() { function HomeScreen() {
const { providersAreSet } = useAuth(); const { providers } = useUserProviders();
const [selectedRepoTitle, setSelectedRepoTitle] = React.useState< const [selectedRepoTitle, setSelectedRepoTitle] = React.useState<
string | null string | null
>(null); >(null);
const providersAreSet = providers.length > 0;
return ( return (
<div <div
data-testid="home-screen" data-testid="home-screen"

View File

@@ -0,0 +1,14 @@
import { Provider } from "#/types/settings";
export const convertRawProvidersToList = (
raw: Partial<Record<Provider, string | null>> | undefined,
): Provider[] => {
if (!raw) return [];
const list: Provider[] = [];
for (const key of Object.keys(raw)) {
if (key) {
list.push(key as Provider);
}
}
return list;
};

View File

@@ -10,7 +10,6 @@ import i18n from "i18next";
import { vi } from "vitest"; import { vi } from "vitest";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { AppStore, RootState, rootReducer } from "./src/store"; import { AppStore, RootState, rootReducer } from "./src/store";
import { AuthProvider } from "#/context/auth-context";
import { ConversationProvider } from "#/context/conversation-context"; import { ConversationProvider } from "#/context/conversation-context";
// Mock useParams before importing components // Mock useParams before importing components
@@ -66,19 +65,17 @@ export function renderWithProviders(
function Wrapper({ children }: PropsWithChildren) { function Wrapper({ children }: PropsWithChildren) {
return ( return (
<Provider store={store}> <Provider store={store}>
<AuthProvider initialProviderTokens={[]}> <QueryClientProvider
<QueryClientProvider client={
client={ new QueryClient({
new QueryClient({ defaultOptions: { queries: { retry: false } },
defaultOptions: { queries: { retry: false } }, })
}) }
} >
> <ConversationProvider>
<ConversationProvider> <I18nextProvider i18n={i18n}>{children}</I18nextProvider>
<I18nextProvider i18n={i18n}>{children}</I18nextProvider> </ConversationProvider>
</ConversationProvider> </QueryClientProvider>
</QueryClientProvider>
</AuthProvider>
</Provider> </Provider>
); );
} }