From e8f48f9b522543fdca88bfae7724812c7fb73afa Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 29 May 2026 13:14:38 +0100 Subject: [PATCH 1/2] fix(site/src/pages/AISettingsPage/ProvidersPage): use password input type and mask after submit --- site/src/api/queries/aiProviders.ts | 2 +- .../UpdateProviderPageView.tsx | 6 + .../components/CredentialField.tsx | 8 +- .../components/ProviderForm.stories.tsx | 153 +++++++++++++++++- .../ProvidersPage/components/ProviderForm.tsx | 60 +++++-- 5 files changed, 212 insertions(+), 17 deletions(-) diff --git a/site/src/api/queries/aiProviders.ts b/site/src/api/queries/aiProviders.ts index ad8d722352c32..7a3f01cf53528 100644 --- a/site/src/api/queries/aiProviders.ts +++ b/site/src/api/queries/aiProviders.ts @@ -8,7 +8,7 @@ import type { const aiProvidersListKey = ["ai", "providers"] as const; -const aiProviderKeyFor = (idOrName: string) => +export const aiProviderKeyFor = (idOrName: string) => [...aiProvidersListKey, idOrName] as const; export const aiProvidersList = () => ({ diff --git a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx index dbeb16003d99f..4309422926b81 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/UpdateProviderPage/UpdateProviderPageView.tsx @@ -7,6 +7,7 @@ import { toast } from "sonner"; import { getErrorMessage } from "#/api/errors"; import { aiProvider, + aiProviderKeyFor, deleteAIProviderMutation, updateAIProviderMutation, } from "#/api/queries/aiProviders"; @@ -167,6 +168,10 @@ const UpdateProviderPageView: React.FC = () => { { enabled: checked }, { onSuccess: (updated) => { + queryClient.setQueryData( + aiProviderKeyFor(providerId), + updated, + ); toast.success( `Provider "${updated.display_name || updated.name}" ${checked ? "enabled" : "disabled"}.`, ); @@ -196,6 +201,7 @@ const UpdateProviderPageView: React.FC = () => { const request = providerFormValuesToUpdate(values, provider); try { const updated = await updateMutation.mutateAsync(request); + queryClient.setQueryData(aiProviderKeyFor(providerId), updated); toast.success( `Provider "${updated.display_name || updated.name}" updated.`, ); diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/CredentialField.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/CredentialField.tsx index 888818f859602..b584d326cd067 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/CredentialField.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/CredentialField.tsx @@ -10,6 +10,7 @@ type CredentialFieldProps = { placeholder?: string; description?: React.ReactNode; required?: boolean; + onBlur?: () => void; onFocus?: () => void; }; @@ -20,6 +21,7 @@ export const CredentialField: React.FC = ({ placeholder, description, required = false, + onBlur, onFocus, }) => { const inputId = useId(); @@ -62,9 +64,13 @@ export const CredentialField: React.FC = ({ { + helpers.onBlur(event); + onBlur?.(); + }} onFocus={onFocus} autoComplete={autoComplete} placeholder={placeholder} diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx index 8fda28fae2644..a4273ceef36ac 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; +import { type ComponentProps, useState } from "react"; import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test"; -import { ProviderForm } from "./ProviderForm"; +import { ProviderForm, SAVED_CREDENTIAL_MASK } from "./ProviderForm"; const meta: Meta = { title: "pages/AISettingsPage/ProviderForm", @@ -15,6 +16,48 @@ const meta: Meta = { export default meta; type Story = StoryObj; +type Deferred = { + promise: Promise; + resolve: () => void; +}; + +const createDeferred = (): Deferred => { + let resolve: (() => void) | undefined; + const promise = new Promise((res) => { + resolve = res; + }); + if (resolve === undefined) { + throw new Error("deferred resolver was not initialized"); + } + return { promise, resolve }; +}; + +const SuccessfulSubmitProviderForm = ({ + args, + deferred, +}: { + args: ComponentProps; + deferred: Deferred; +}) => { + const [isLoading, setIsLoading] = useState(false); + + return ( + { + args.onSubmit?.(values); + setIsLoading(true); + await deferred.promise; + setIsLoading(false); + }} + /> + ); +}; + +let bedrockSubmitDeferred = createDeferred(); +let apiKeySubmitDeferred = createDeferred(); + export const AddAnthropicDefault: Story = {}; export const AddOpenAI: Story = { @@ -47,6 +90,15 @@ export const AddBedrock: Story = { }; export const EditBedrockKeepCredentials: Story = { + render: (args) => { + bedrockSubmitDeferred = createDeferred(); + return ( + + ); + }, args: { editing: true, bedrockSavedAccessCredentials: true, @@ -62,6 +114,58 @@ export const EditBedrockKeepCredentials: Story = { enabled: true, }, }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const accessKeyInput = await canvas.findByLabelText(/^access key$/i); + const accessKeySecretInput = + await canvas.findByLabelText(/access key secret/i); + + expect(accessKeyInput).toHaveProperty("type", "text"); + expect(accessKeySecretInput).toHaveProperty("type", "text"); + expect(accessKeyInput).toHaveValue(SAVED_CREDENTIAL_MASK); + expect(accessKeySecretInput).toHaveValue(SAVED_CREDENTIAL_MASK); + + await userEvent.click(accessKeyInput); + await waitFor(() => expect(accessKeyInput).toHaveValue("")); + await userEvent.click(accessKeySecretInput); + await waitFor(() => + expect(accessKeyInput).toHaveValue(SAVED_CREDENTIAL_MASK), + ); + + await userEvent.click(accessKeyInput); + await waitFor(() => expect(accessKeyInput).toHaveValue("")); + await userEvent.type(accessKeyInput, "AKIAI1lO0EXAMPLE"); + expect(accessKeyInput).toHaveValue("AKIAI1lO0EXAMPLE"); + + await userEvent.click(accessKeySecretInput); + await userEvent.type(accessKeySecretInput, "wJalrI1lO0Secret"); + expect(accessKeySecretInput).toHaveValue("wJalrI1lO0Secret"); + + const displayName = canvas.getByLabelText(/display name/i); + await userEvent.clear(displayName); + await userEvent.type(displayName, "Updated Bedrock"); + + const submitButton = canvas.getByRole("button", { + name: /update provider/i, + }); + await waitFor(() => expect(submitButton).toBeEnabled()); + await userEvent.click(submitButton); + + await waitFor(() => + expect(args.onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + accessKey: "AKIAI1lO0EXAMPLE", + accessKeySecret: "wJalrI1lO0Secret", + }), + ), + ); + await waitFor(() => expect(submitButton).toBeDisabled()); + bedrockSubmitDeferred.resolve(); + await waitFor(() => { + expect(accessKeyInput).toHaveValue(SAVED_CREDENTIAL_MASK); + expect(accessKeySecretInput).toHaveValue(SAVED_CREDENTIAL_MASK); + }); + }, }; export const EditProvider: Story = { @@ -109,6 +213,15 @@ export const Submitting: Story = { }; export const CredentialFocusClear: Story = { + render: (args) => { + apiKeySubmitDeferred = createDeferred(); + return ( + + ); + }, args: { editing: true, openAiAnthropicSavedApiKey: true, @@ -122,12 +235,48 @@ export const CredentialFocusClear: Story = { enabled: true, }, }, - play: async ({ canvasElement }) => { + play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); const apiKeyInput = await canvas.findByLabelText(/api key/i); + + expect(apiKeyInput).toHaveProperty("type", "text"); expect(apiKeyInput).toHaveValue("sk-ant-***\u2026***ABCD"); + await userEvent.click(apiKeyInput); await waitFor(() => expect(apiKeyInput).toHaveValue("")); + + const displayName = canvas.getByLabelText(/display name/i); + await userEvent.click(displayName); + await waitFor(() => + expect(apiKeyInput).toHaveValue("sk-ant-***\u2026***ABCD"), + ); + + await userEvent.click(apiKeyInput); + await waitFor(() => expect(apiKeyInput).toHaveValue("")); + await userEvent.type(apiKeyInput, "sk-ant-I1lO0-new-secret"); + expect(apiKeyInput).toHaveValue("sk-ant-I1lO0-new-secret"); + + await userEvent.clear(displayName); + await userEvent.type(displayName, "Updated Anthropic"); + + const submitButton = canvas.getByRole("button", { + name: /update provider/i, + }); + await waitFor(() => expect(submitButton).toBeEnabled()); + await userEvent.click(submitButton); + + await waitFor(() => + expect(args.onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: "sk-ant-I1lO0-new-secret", + }), + ), + ); + await waitFor(() => expect(submitButton).toBeDisabled()); + apiKeySubmitDeferred.resolve(); + await waitFor(() => + expect(apiKeyInput).toHaveValue("sk-ant-***\u2026***ABCD"), + ); }, }; export const UnsavedChangesPrompt: Story = { diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx index 0a609de2acd68..51305b645efe6 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx @@ -238,6 +238,20 @@ export const ProviderForm: FC = ({ const typeDefaults = providerDefaults[resolvedType as keyof typeof providerDefaults]; + // Seed Bedrock credentials with the mask when on file; focus clears it, + // and a re-submitted "" tells the API mapping to keep the value. + const maskedAccessKey = bedrockSavedAccessCredentials + ? SAVED_CREDENTIAL_MASK + : ""; + const maskedAccessKeySecret = bedrockSavedAccessCredentials + ? SAVED_CREDENTIAL_MASK + : ""; + // Same pattern for openai/anthropic. Prefer the API-supplied masked + // rendering so the user sees the key's identifying suffix. + const maskedApiKey = openAiAnthropicSavedApiKey + ? (openAiAnthropicMaskedApiKey ?? SAVED_CREDENTIAL_MASK) + : ""; + const form = useFormik({ initialValues: { ...defaultInitialValues, @@ -245,17 +259,9 @@ export const ProviderForm: FC = ({ // Edit overrides prefills with server values; create gets them as-is. ...(typeDefaults ?? {}), ...initialValues, - // Seed Bedrock credentials with the mask when on file; focus clears it, - // and a re-submitted "" tells the API mapping to keep the value. - accessKey: bedrockSavedAccessCredentials ? SAVED_CREDENTIAL_MASK : "", - accessKeySecret: bedrockSavedAccessCredentials - ? SAVED_CREDENTIAL_MASK - : "", - // Same pattern for openai/anthropic. Prefer the API-supplied masked - // rendering so the user sees the key's identifying suffix. - apiKey: openAiAnthropicSavedApiKey - ? (openAiAnthropicMaskedApiKey ?? SAVED_CREDENTIAL_MASK) - : "", + accessKey: maskedAccessKey, + accessKeySecret: maskedAccessKeySecret, + apiKey: maskedApiKey, }, validationSchema: getProviderFormSchema(editing), validateOnMount: true, @@ -276,6 +282,15 @@ export const ProviderForm: FC = ({ } }; + const handleCredentialBlur = ( + field: "apiKey" | "accessKey" | "accessKeySecret", + ) => { + const initial = form.initialValues[field]; + if (form.values[field] === "" && initial !== "") { + void form.setFieldValue(field, initial); + } + }; + // When the parent's mutation finishes without an error, treat the just- // submitted values as the new baseline so the unsaved-changes prompt does // not fire on subsequent navigations. React Query reports a missing error @@ -283,10 +298,25 @@ export const ProviderForm: FC = ({ const previousIsLoading = useRef(isLoading); useEffect(() => { if (previousIsLoading.current && !isLoading && !submitError) { - form.resetForm({ values: form.values }); + // Restore credential fields to their initial masked sentinels so + // the raw key is never left visible after a successful save. + const remaskedValues = { + ...form.values, + apiKey: maskedApiKey, + accessKey: maskedAccessKey, + accessKeySecret: maskedAccessKeySecret, + }; + form.resetForm({ values: remaskedValues }); } previousIsLoading.current = isLoading; - }, [isLoading, submitError, form]); + }, [ + isLoading, + submitError, + form, + maskedApiKey, + maskedAccessKey, + maskedAccessKeySecret, + ]); const unsavedChanges = useUnsavedChangesPrompt( form.dirty && !form.isSubmitting, @@ -327,6 +357,7 @@ export const ProviderForm: FC = ({ required label="API key" helpers={getFieldHelpers("apiKey")} + onBlur={() => handleCredentialBlur("apiKey")} onFocus={() => handleCredentialFocus("apiKey")} autoComplete="new-password" placeholder={apiKeyPlaceholder(form.values.type)} @@ -389,12 +420,15 @@ export const ProviderForm: FC = ({ required label="Access key" helpers={getFieldHelpers("accessKey")} + onBlur={() => handleCredentialBlur("accessKey")} onFocus={() => handleCredentialFocus("accessKey")} + autoComplete="new-password" /> handleCredentialBlur("accessKeySecret")} onFocus={() => handleCredentialFocus("accessKeySecret")} autoComplete="new-password" /> From 253b73809f7096eacc01af946789e9213ac55ab6 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Fri, 29 May 2026 19:50:15 +0100 Subject: [PATCH 2/2] address R2 findings --- .../components/ProviderForm.stories.tsx | 179 ++++++++++++++++-- .../ProvidersPage/components/ProviderForm.tsx | 31 +-- .../pages/AgentsPage/AgentChatPage.test.ts | 17 +- .../hooks/useChatDraftAttachments.test.ts | 17 +- site/src/testHelpers/deferred.ts | 15 ++ 5 files changed, 196 insertions(+), 63 deletions(-) create mode 100644 site/src/testHelpers/deferred.ts diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx index a4273ceef36ac..9e51b01254dec 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { type ComponentProps, useState } from "react"; import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test"; +import { createDeferred, type Deferred } from "#/testHelpers/deferred"; import { ProviderForm, SAVED_CREDENTIAL_MASK } from "./ProviderForm"; const meta: Meta = { @@ -16,47 +17,87 @@ const meta: Meta = { export default meta; type Story = StoryObj; -type Deferred = { - promise: Promise; - resolve: () => void; -}; +const SuccessfulSubmitProviderForm = ({ + args, + deferred, +}: { + args: ComponentProps; + deferred: Deferred; +}) => { + const [isLoading, setIsLoading] = useState(false); -const createDeferred = (): Deferred => { - let resolve: (() => void) | undefined; - const promise = new Promise((res) => { - resolve = res; - }); - if (resolve === undefined) { - throw new Error("deferred resolver was not initialized"); - } - return { promise, resolve }; + return ( + { + args.onSubmit?.(values); + setIsLoading(true); + await deferred.promise; + setIsLoading(false); + }} + /> + ); }; -const SuccessfulSubmitProviderForm = ({ +const FailedSubmitProviderForm = ({ args, deferred, }: { args: ComponentProps; - deferred: Deferred; + deferred: Deferred; }) => { const [isLoading, setIsLoading] = useState(false); + const [submitError, setSubmitError] = useState(); return ( { args.onSubmit?.(values); setIsLoading(true); await deferred.promise; + setSubmitError(new Error(errorSubmitMessage)); setIsLoading(false); }} /> ); }; -let bedrockSubmitDeferred = createDeferred(); -let apiKeySubmitDeferred = createDeferred(); +const ExternalLoadingProviderForm = ({ + args, + deferred, +}: { + args: ComponentProps; + deferred: Deferred; +}) => { + const [isLoading, setIsLoading] = useState(false); + + return ( + <> + + + + ); +}; + +const errorSubmitMessage = "Failed to update provider."; + +let bedrockSubmitDeferred = createDeferred(); +let apiKeySubmitDeferred = createDeferred(); +let failedSubmitDeferred = createDeferred(); +let externalSaveDeferred = createDeferred(); export const AddAnthropicDefault: Story = {}; @@ -91,7 +132,7 @@ export const AddBedrock: Story = { export const EditBedrockKeepCredentials: Story = { render: (args) => { - bedrockSubmitDeferred = createDeferred(); + bedrockSubmitDeferred = createDeferred(); return ( { const canvas = within(canvasElement); - const accessKeyInput = await canvas.findByLabelText(/^access key$/i); + const accessKeyInput = await canvas.findByLabelText(/^access key\s*\*?$/i); const accessKeySecretInput = await canvas.findByLabelText(/access key secret/i); @@ -138,6 +179,7 @@ export const EditBedrockKeepCredentials: Story = { expect(accessKeyInput).toHaveValue("AKIAI1lO0EXAMPLE"); await userEvent.click(accessKeySecretInput); + await waitFor(() => expect(accessKeySecretInput).toHaveValue("")); await userEvent.type(accessKeySecretInput, "wJalrI1lO0Secret"); expect(accessKeySecretInput).toHaveValue("wJalrI1lO0Secret"); @@ -214,7 +256,7 @@ export const Submitting: Story = { export const CredentialFocusClear: Story = { render: (args) => { - apiKeySubmitDeferred = createDeferred(); + apiKeySubmitDeferred = createDeferred(); return ( { + failedSubmitDeferred = createDeferred(); + return ( + + ); + }, + args: { + editing: true, + openAiAnthropicSavedApiKey: true, + openAiAnthropicMaskedApiKey: "sk-ant-***\u2026***ABCD", + initialValues: { + type: "anthropic", + name: "production-anthropic", + displayName: "Production Anthropic", + baseUrl: "https://api.anthropic.com", + apiKey: "", + enabled: true, + }, + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const apiKeyInput = await canvas.findByLabelText(/api key/i); + + await userEvent.click(apiKeyInput); + await waitFor(() => expect(apiKeyInput).toHaveValue("")); + await userEvent.type(apiKeyInput, "sk-ant-I1lO0-new-secret"); + + const displayName = canvas.getByLabelText(/display name/i); + await userEvent.clear(displayName); + await userEvent.type(displayName, "Failed Anthropic"); + + const submitButton = canvas.getByRole("button", { + name: /update provider/i, + }); + await waitFor(() => expect(submitButton).toBeEnabled()); + await userEvent.click(submitButton); + + await waitFor(() => + expect(args.onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + apiKey: "sk-ant-I1lO0-new-secret", + }), + ), + ); + await waitFor(() => expect(submitButton).toBeDisabled()); + failedSubmitDeferred.resolve(); + await expect(await canvas.findByText(errorSubmitMessage)).toBeVisible(); + expect(apiKeyInput).toHaveValue("sk-ant-I1lO0-new-secret"); + }, +}; + +export const ExternalLoadingKeepsCredential: Story = { + render: (args) => { + externalSaveDeferred = createDeferred(); + return ( + + ); + }, + args: { + editing: true, + openAiAnthropicSavedApiKey: true, + openAiAnthropicMaskedApiKey: "sk-ant-***\u2026***ABCD", + initialValues: { + type: "anthropic", + name: "production-anthropic", + displayName: "Production Anthropic", + baseUrl: "https://api.anthropic.com", + apiKey: "", + enabled: true, + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const apiKeyInput = await canvas.findByLabelText(/api key/i); + const submitButton = canvas.getByRole("button", { + name: /update provider/i, + }); + + await userEvent.click(apiKeyInput); + await waitFor(() => expect(apiKeyInput).toHaveValue("")); + await userEvent.type(apiKeyInput, "sk-ant-I1lO0-new-secret"); + await waitFor(() => expect(submitButton).toBeEnabled()); + + await userEvent.click( + canvas.getByRole("button", { name: /simulate external save/i }), + ); + await waitFor(() => expect(submitButton).toBeDisabled()); + externalSaveDeferred.resolve(); + await waitFor(() => expect(submitButton).toBeEnabled()); + expect(apiKeyInput).toHaveValue("sk-ant-I1lO0-new-secret"); + }, +}; + export const UnsavedChangesPrompt: Story = { args: { editing: true, diff --git a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx index 51305b645efe6..8aa8a2058c72d 100644 --- a/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx +++ b/site/src/pages/AISettingsPage/ProvidersPage/components/ProviderForm.tsx @@ -252,6 +252,7 @@ export const ProviderForm: FC = ({ ? (openAiAnthropicMaskedApiKey ?? SAVED_CREDENTIAL_MASK) : ""; + const didSubmit = useRef(false); const form = useFormik({ initialValues: { ...defaultInitialValues, @@ -265,7 +266,10 @@ export const ProviderForm: FC = ({ }, validationSchema: getProviderFormSchema(editing), validateOnMount: true, - onSubmit: onSubmit ?? (() => {}), + onSubmit: (values) => { + didSubmit.current = true; + return onSubmit?.(values); + }, }); const getFieldHelpers = getFormHelpers(form, submitError); @@ -282,6 +286,8 @@ export const ProviderForm: FC = ({ } }; + // Restores the mask when the user leaves the field without entering + // a new value, keeping the saved-credential appearance. const handleCredentialBlur = ( field: "apiKey" | "accessKey" | "accessKeySecret", ) => { @@ -297,16 +303,19 @@ export const ProviderForm: FC = ({ // as `null`, so a truthy check covers both null and undefined. const previousIsLoading = useRef(isLoading); useEffect(() => { - if (previousIsLoading.current && !isLoading && !submitError) { - // Restore credential fields to their initial masked sentinels so - // the raw key is never left visible after a successful save. - const remaskedValues = { - ...form.values, - apiKey: maskedApiKey, - accessKey: maskedAccessKey, - accessKeySecret: maskedAccessKeySecret, - }; - form.resetForm({ values: remaskedValues }); + if (previousIsLoading.current && !isLoading) { + if (didSubmit.current && !submitError) { + // Restore credential fields to their initial masked sentinels so + // the raw key is never left visible after a successful save. + const remaskedValues = { + ...form.values, + apiKey: maskedApiKey, + accessKey: maskedAccessKey, + accessKeySecret: maskedAccessKeySecret, + }; + form.resetForm({ values: remaskedValues }); + } + didSubmit.current = false; } previousIsLoading.current = isLoading; }, [ diff --git a/site/src/pages/AgentsPage/AgentChatPage.test.ts b/site/src/pages/AgentsPage/AgentChatPage.test.ts index 21b7853879593..cae88d5bf6a56 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.test.ts +++ b/site/src/pages/AgentsPage/AgentChatPage.test.ts @@ -2,6 +2,7 @@ import { act, renderHook } from "@testing-library/react"; import { createRef } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ChatQueuedMessage } from "#/api/typesGenerated"; +import { createDeferred } from "#/testHelpers/deferred"; import { draftInputStorageKeyPrefix, getPersistedDraftInputValue, @@ -77,22 +78,6 @@ const setMobileViewport = (isMobile: boolean) => { }); }; -type Deferred = { - promise: Promise; - resolve: (value: T | PromiseLike) => void; - reject: (reason?: unknown) => void; -}; - -const createDeferred = (): Deferred => { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -}; - describe("waitForPendingChatSettingsSyncs", () => { it("waits for plan-mode and workspace updates before resolving", async () => { const planModeUpdate = createDeferred(); diff --git a/site/src/pages/AgentsPage/hooks/useChatDraftAttachments.test.ts b/site/src/pages/AgentsPage/hooks/useChatDraftAttachments.test.ts index 72ba18c0d5f59..ec94f93ab0e61 100644 --- a/site/src/pages/AgentsPage/hooks/useChatDraftAttachments.test.ts +++ b/site/src/pages/AgentsPage/hooks/useChatDraftAttachments.test.ts @@ -1,28 +1,13 @@ import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { API } from "#/api/api"; +import { createDeferred } from "#/testHelpers/deferred"; import { chatDraftAttachmentStorageKey } from "../utils/chatDraftAttachmentStorage"; import { resetChatDraftAttachmentRegistryForTest, useChatDraftAttachments, } from "./useChatDraftAttachments"; -type Deferred = { - promise: Promise; - resolve: (value: T | PromiseLike) => void; - reject: (reason?: unknown) => void; -}; - -const createDeferred = (): Deferred => { - let resolve!: (value: T | PromiseLike) => void; - let reject!: (reason?: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -}; - const orgID = "org-1"; const chatID = "chat-a"; const storageKey = chatDraftAttachmentStorageKey(orgID, chatID); diff --git a/site/src/testHelpers/deferred.ts b/site/src/testHelpers/deferred.ts new file mode 100644 index 0000000000000..2ae217ece5a4f --- /dev/null +++ b/site/src/testHelpers/deferred.ts @@ -0,0 +1,15 @@ +export type Deferred = { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +}; + +export const createDeferred = (): Deferred => { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +};