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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 302 additions and 390 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,16 +1,16 @@
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 { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { setupStore } from "test-utils";
import { Provider } from "react-redux";
import { createRoutesStub, Outlet } from "react-router";
import OpenHands from "#/api/open-hands";
import { AuthProvider } from "#/context/auth-context";
import { GitRepository } from "#/types/git";
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 RouterStub = createRoutesStub([
{
@ -40,11 +40,9 @@ const renderRepoConnector = (initialProvidersAreSet = true) => {
return render(<RouterStub />, {
wrapper: ({ children }) => (
<Provider store={setupStore()}>
<AuthProvider initialProvidersAreSet={initialProvidersAreSet}>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</AuthProvider>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</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", () => {
it("should render the repository connector section", () => {
renderRepoConnector();
@ -99,7 +108,7 @@ describe("RepoConnector", () => {
renderRepoConnector();
const launchButton = screen.getByTestId("repo-launch-button");
const launchButton = await screen.findByTestId("repo-launch-button");
expect(launchButton).toBeDisabled();
// Wait for the loading state to be replaced with the dropdown
@ -147,7 +156,7 @@ describe("RepoConnector", () => {
const repoConnector = screen.getByTestId("repo-connector");
const launchButton =
within(repoConnector).getByTestId("repo-launch-button");
await within(repoConnector).findByTestId("repo-launch-button");
await userEvent.click(launchButton);
// repo not selected yet
@ -184,7 +193,7 @@ describe("RepoConnector", () => {
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
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 () => {
renderRepoConnector(true);
expect(
screen.queryByTestId("navigate-to-settings-button"),
).not.toBeInTheDocument();
renderRepoConnector();
await waitFor(() => {
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 () => {
renderRepoConnector(false);
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
provider_tokens_set: {},
});
renderRepoConnector();
const goToSettingsButton = await screen.findByTestId(
"navigate-to-settings-button",

View File

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

View File

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

View File

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

View File

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

View File

@ -3,18 +3,15 @@ import { describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { AuthProvider } from "#/context/auth-context";
describe("useSaveSettings", () => {
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 { result } = renderHook(() => useSaveSettings(), {
wrapper: ({ children }) => (
<AuthProvider>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
</AuthProvider>
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});

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 OpenHands from "#/api/open-hands";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { AuthProvider } from "#/context/auth-context";
import { AvailableLanguages } from "#/i18n";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
@ -14,7 +13,7 @@ const renderAppSettingsScreen = () =>
render(<AppSettingsScreen />, {
wrapper: ({ children }) => (
<QueryClientProvider client={new QueryClient()}>
<AuthProvider>{children}</AuthProvider>
{children}
</QueryClientProvider>
),
});

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -1,5 +1,4 @@
import React from "react";
import posthog from "posthog-js";
import { useLocation } from "react-router";
import { useGitUser } from "#/hooks/query/use-git-user";
import { UserActions } from "./user-actions";
@ -26,7 +25,7 @@ export function Sidebar() {
isError: settingsIsError,
isFetching: isFetchingSettings,
} = useSettings();
const { mutateAsync: logout } = useLogout();
const { mutate: logout } = useLogout();
const [settingsModalIsOpen, setSettingsModalIsOpen] = React.useState(false);
@ -62,11 +61,6 @@ export function Sidebar() {
location.pathname,
]);
const handleLogout = async () => {
await logout();
posthog.reset();
};
return (
<>
<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.data ? { avatar_url: user.data.avatar_url } : undefined
}
onLogout={handleLogout}
onLogout={logout}
isLoading={user.isFetching}
/>
</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,
} from "#/types/core/actions";
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 =>
typeof event === "object" &&
@ -128,7 +128,7 @@ export function WsClientProvider({
);
const [events, setEvents] = React.useState<Record<string, unknown>[]>([]);
const lastEventRef = React.useRef<Record<string, unknown> | null>(null);
const { providerTokensSet } = useAuth();
const { providers } = useUserProviders();
const messageRateHandler = useRate({ threshold: 250 });
@ -224,7 +224,7 @@ export function WsClientProvider({
const query = {
latest_event_id: lastEvent?.id ?? -1,
conversation_id: conversationId,
providers_set: providerTokensSet,
providers_set: providers,
};
const baseUrl =

View File

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

View File

@ -1,38 +1,23 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import posthog from "posthog-js";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { useConfig } from "../query/use-config";
export const useLogout = () => {
const { setProviderTokensSet, setProvidersAreSet } = useAuth();
const queryClient = useQueryClient();
const { data: config } = useConfig();
const navigate = useNavigate();
return useMutation({
mutationFn: async () => {
// Pause all queries that depend on githubTokenIsSet
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"] });
mutationFn: () => OpenHands.logout(config?.APP_MODE ?? "oss"),
onSuccess: async () => {
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 ApiKeysClient from "#/api/api-keys";
import { useConfig } from "./use-config";
import { useAuth } from "#/context/auth-context";
export const API_KEYS_QUERY_KEY = "api-keys";
export function useApiKeys() {
const { providersAreSet } = useAuth();
const { data: config } = useConfig();
return useQuery({
queryKey: [API_KEYS_QUERY_KEY],
enabled: providersAreSet && config?.APP_MODE === "saas",
enabled: config?.APP_MODE === "saas",
queryFn: async () => {
const keys = await ApiKeysClient.getApiKeys();
return Array.isArray(keys) ? keys : [];

View File

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

View File

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

View File

@ -2,10 +2,10 @@ import { useQuery } from "@tanstack/react-query";
import React from "react";
import posthog from "posthog-js";
import OpenHands from "#/api/open-hands";
import { useAuth } from "#/context/auth-context";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { useIsOnTosPage } from "#/hooks/use-is-on-tos-page";
import { Settings } from "#/types/settings";
import { useIsAuthed } from "./use-is-authed";
const getSettingsQueryFn = async (): Promise<Settings> => {
const apiSettings = await OpenHands.getSettings();
@ -30,13 +30,11 @@ const getSettingsQueryFn = async (): Promise<Settings> => {
};
export const useSettings = () => {
const { setProviderTokensSet, providerTokensSet, setProvidersAreSet } =
useAuth();
const isOnTosPage = useIsOnTosPage();
const { data: userIsAuthenticated } = useIsAuthed();
const query = useQuery({
queryKey: ["settings", providerTokensSet],
queryKey: ["settings"],
queryFn: getSettingsQueryFn,
// Only retry if the error is not a 404 because we
// would want to show the modal immediately if the
@ -44,7 +42,7 @@ export const useSettings = () => {
retry: (_, error) => error.status !== 404,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
enabled: !isOnTosPage,
enabled: !isOnTosPage && !!userIsAuthenticated,
meta: {
disableToast: true,
},
@ -56,18 +54,6 @@ export const useSettings = () => {
}
}, [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
// 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:

View File

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

View File

@ -1,15 +1,10 @@
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "#/context/auth-context";
import OpenHands from "#/api/open-hands";
export const useUserRepositories = () => {
const { providerTokensSet, providersAreSet } = useAuth();
return useQuery({
queryKey: ["repositories", providerTokensSet],
export const useUserRepositories = () =>
useQuery({
queryKey: ["repositories"],
queryFn: OpenHands.retrieveUserGitRepositories,
enabled: providersAreSet,
staleTime: 1000 * 60 * 5, // 5 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 { GetConfigResponse } from "#/api/open-hands.types";
import { useAuth } from "#/context/auth-context";
interface UseAuthUrlConfig {
appMode: GetConfigResponse["APP_MODE"] | null;
@ -9,21 +7,12 @@ interface 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(() => {
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]);
return null;
};

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

View File

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

View File

@ -3,16 +3,18 @@ import { PrefetchPageLinks } from "react-router";
import { HomeHeader } from "#/components/features/home/home-header";
import { RepoConnector } from "#/components/features/home/repo-connector";
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" />;
function HomeScreen() {
const { providersAreSet } = useAuth();
const { providers } = useUserProviders();
const [selectedRepoTitle, setSelectedRepoTitle] = React.useState<
string | null
>(null);
const providersAreSet = providers.length > 0;
return (
<div
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 { AxiosError } from "axios";
import { AppStore, RootState, rootReducer } from "./src/store";
import { AuthProvider } from "#/context/auth-context";
import { ConversationProvider } from "#/context/conversation-context";
// Mock useParams before importing components
@ -66,19 +65,17 @@ export function renderWithProviders(
function Wrapper({ children }: PropsWithChildren) {
return (
<Provider store={store}>
<AuthProvider initialProviderTokens={[]}>
<QueryClientProvider
client={
new QueryClient({
defaultOptions: { queries: { retry: false } },
})
}
>
<ConversationProvider>
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
</ConversationProvider>
</QueryClientProvider>
</AuthProvider>
<QueryClientProvider
client={
new QueryClient({
defaultOptions: { queries: { retry: false } },
})
}
>
<ConversationProvider>
<I18nextProvider i18n={i18n}>{children}</I18nextProvider>
</ConversationProvider>
</QueryClientProvider>
</Provider>
);
}