diff --git a/src/browser/components/ProjectSidebar.tsx b/src/browser/components/ProjectSidebar.tsx index 2e58ef91d9..8558aa7dc1 100644 --- a/src/browser/components/ProjectSidebar.tsx +++ b/src/browser/components/ProjectSidebar.tsx @@ -43,14 +43,13 @@ import { import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; import { SidebarCollapseButton } from "./ui/SidebarCollapseButton"; import { ConfirmationModal } from "./ConfirmationModal"; -import SecretsModal from "./SecretsModal"; -import type { Secret } from "@/common/types/secrets"; +import { useSettings } from "@/browser/contexts/SettingsContext"; import { WorkspaceListItem, type WorkspaceSelection } from "./WorkspaceListItem"; import { WorkspaceStatusIndicator } from "./WorkspaceStatusIndicator"; import { RenameProvider } from "@/browser/contexts/WorkspaceRenameContext"; import { useProjectContext } from "@/browser/contexts/ProjectContext"; -import { ChevronRight, CircleHelp, KeyRound } from "lucide-react"; +import { ChevronRight, CircleHelp, Settings } from "lucide-react"; import { MUX_HELP_CHAT_WORKSPACE_ID } from "@/common/constants/muxChat"; import { useWorkspaceContext } from "@/browser/contexts/WorkspaceContext"; import { useRouter } from "@/browser/contexts/RouterContext"; @@ -359,14 +358,13 @@ const ProjectSidebarInner: React.FC = ({ projects, openProjectCreateModal: onAddProject, removeProject: onRemoveProject, - getSecrets: onGetSecrets, - updateSecrets: onUpdateSecrets, createSection, updateSection, removeSection, reorderSections, assignWorkspaceToSection, } = useProjectContext(); + const { openProjectSettings } = useSettings(); // Theme for logo variant const { theme } = useTheme(); @@ -467,12 +465,6 @@ const ProjectSidebarInner: React.FC = ({ } | null>(null); const projectRemoveError = usePopoverError(); const sectionRemoveError = usePopoverError(); - const [secretsModalState, setSecretsModalState] = useState<{ - isOpen: boolean; - projectPath: string; - projectName: string; - secrets: Secret[]; - } | null>(null); const getProjectName = (path: string) => { if (!path || typeof path !== "string") { @@ -608,26 +600,6 @@ const ProjectSidebarInner: React.FC = ({ } }; - const handleOpenSecrets = async (projectPath: string) => { - const secrets = await onGetSecrets(projectPath); - setSecretsModalState({ - isOpen: true, - projectPath, - projectName: getProjectName(projectPath), - secrets, - }); - }; - - const handleSaveSecrets = async (secrets: Secret[]) => { - if (secretsModalState) { - await onUpdateSecrets(secretsModalState.projectPath, secrets); - } - }; - - const handleCloseSecrets = () => { - setSecretsModalState(null); - }; - // UI preference: project order persists in localStorage const [projectOrder, setProjectOrder] = usePersistedState("mux:projectOrder", []); @@ -834,16 +806,16 @@ const ProjectSidebarInner: React.FC = ({ - Manage secrets + Project settings @@ -1304,16 +1276,6 @@ const ProjectSidebarInner: React.FC = ({ side="left" shortcut={formatKeybind(KEYBINDS.TOGGLE_SIDEBAR)} /> - {secretsModalState && ( - - )} = ({ visible }) => { - if (visible) { - // Eye-off icon (with slash) - password is visible - return ( - - - - - ); - } - - // Eye icon - password is hidden - return ( - - - - - ); -}; - -interface SecretsModalProps { - isOpen: boolean; - projectPath: string; - projectName: string; - initialSecrets: Secret[]; - onClose: () => void; - onSave: (secrets: Secret[]) => Promise; -} - -const SecretsModal: React.FC = ({ - isOpen, - projectPath: _projectPath, - projectName, - initialSecrets, - onClose, - onSave, -}) => { - const [secrets, setSecrets] = useState(initialSecrets); - const [visibleSecrets, setVisibleSecrets] = useState>(new Set()); - const [isLoading, setIsLoading] = useState(false); - - // Reset state when modal opens with new secrets - useEffect(() => { - if (isOpen) { - setSecrets(initialSecrets); - setVisibleSecrets(new Set()); - } - }, [isOpen, initialSecrets]); - - const handleCancel = useCallback(() => { - setSecrets(initialSecrets); - setVisibleSecrets(new Set()); - onClose(); - }, [initialSecrets, onClose]); - - const handleSave = async () => { - setIsLoading(true); - try { - // Filter out empty secrets - const validSecrets = secrets.filter((s) => s.key.trim() !== "" && s.value.trim() !== ""); - await onSave(validSecrets); - onClose(); - } catch (err) { - console.error("Failed to save secrets:", err); - } finally { - setIsLoading(false); - } - }; - - const addSecret = () => { - setSecrets([...secrets, { key: "", value: "" }]); - }; - - const removeSecret = (index: number) => { - setSecrets(secrets.filter((_, i) => i !== index)); - // Clean up visibility state - const newVisible = new Set(visibleSecrets); - newVisible.delete(index); - setVisibleSecrets(newVisible); - }; - - const updateSecret = (index: number, field: "key" | "value", value: string) => { - const newSecrets = [...secrets]; - // Auto-capitalize key field for env variable convention - const processedValue = field === "key" ? value.toUpperCase() : value; - newSecrets[index] = { ...newSecrets[index], [field]: processedValue }; - setSecrets(newSecrets); - }; - - const toggleVisibility = (index: number) => { - const newVisible = new Set(visibleSecrets); - if (newVisible.has(index)) { - newVisible.delete(index); - } else { - newVisible.add(index); - } - setVisibleSecrets(newVisible); - }; - - const handleOpenChange = useCallback( - (open: boolean) => { - if (!open && !isLoading) { - handleCancel(); - } - }, - [isLoading, handleCancel] - ); - - return ( - - - - Manage Secrets - Project: {projectName} - - -

- Secrets are stored in ~/.mux/secrets.json (kept away from source code) but - namespaced per project. -

-

Secrets are injected as environment variables to compute commands (e.g. Bash)

-
- -
- {secrets.length === 0 ? ( -
- No secrets configured -
- ) : ( -
- - -
{/* Empty cell for eye icon column */} -
{/* Empty cell for delete button column */} - {secrets.map((secret, index) => ( - - updateSecret(index, "key", e.target.value)} - placeholder="SECRET_NAME" - disabled={isLoading} - className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-dim w-full rounded border px-2.5 py-1.5 font-mono text-[13px] text-white focus:outline-none" - /> - updateSecret(index, "value", e.target.value)} - placeholder="secret value" - disabled={isLoading} - className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-dim w-full rounded border px-2.5 py-1.5 font-mono text-[13px] text-white focus:outline-none" - /> - - - - ))} -
- )} -
- - - - - - - - -
- ); -}; - -export default SecretsModal; diff --git a/src/browser/components/Settings/sections/ProjectSecretsSection.tsx b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx new file mode 100644 index 0000000000..b320caf152 --- /dev/null +++ b/src/browser/components/Settings/sections/ProjectSecretsSection.tsx @@ -0,0 +1,361 @@ +/** + * Project secrets management section for Settings modal. + * Manages environment secrets that are injected into agent tools. + */ + +import React, { useState, useEffect, useCallback } from "react"; +import { Eye, EyeOff, Trash2, Plus, Import, Loader2 } from "lucide-react"; +import { Button } from "@/browser/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/browser/components/ui/select"; +import type { Secret } from "@/common/types/secrets"; +import { useProjectContext } from "@/browser/contexts/ProjectContext"; + +interface ProjectSecretsSectionProps { + projectPath: string; + /** Called after secrets are successfully saved, with the new list of keys */ + onSecretsChanged?: (keys: string[]) => void; + /** Called when hasChanges state changes, allowing parent to block project switching */ + onHasChangesChange?: (hasChanges: boolean) => void; +} + +export const ProjectSecretsSection: React.FC = ({ + projectPath, + onSecretsChanged, + onHasChangesChange, +}) => { + const { projects, getSecrets, updateSecrets } = useProjectContext(); + const [secrets, setSecrets] = useState([]); + const [visibleSecrets, setVisibleSecrets] = useState>(new Set()); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const [hasChanges, setHasChanges] = useState(false); + const [originalSecrets, setOriginalSecrets] = useState([]); + // Use test-only import buttons because Radix portals/select interactions are unreliable in happy-dom. + const isTestEnv = import.meta.env.MODE === "test"; + + // Get other projects (excluding current one) for import dropdown + const otherProjects = Array.from(projects.entries()).filter(([path]) => path !== projectPath); + + // Load secrets when project changes + useEffect(() => { + // Guard against empty projectPath (happens during initial Settings render) + if (!projectPath) { + setSecrets([]); + setOriginalSecrets([]); + setIsLoading(false); + return; + } + + let cancelled = false; + setIsLoading(true); + setHasChanges(false); + // Reset import state when project changes to avoid stuck isImporting + setIsImporting(false); + + (async () => { + try { + const loaded = await getSecrets(projectPath); + if (cancelled) return; + setSecrets(loaded); + setOriginalSecrets(loaded); + setVisibleSecrets(new Set()); + } catch (err) { + if (cancelled) return; + console.error("Failed to load secrets:", err); + setSecrets([]); + setOriginalSecrets([]); + } finally { + if (!cancelled) setIsLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [getSecrets, projectPath]); + + // Track changes and notify parent + useEffect(() => { + const changed = + secrets.length !== originalSecrets.length || + secrets.some( + (s, i) => s.key !== originalSecrets[i]?.key || s.value !== originalSecrets[i]?.value + ); + setHasChanges(changed); + onHasChangesChange?.(changed); + }, [secrets, originalSecrets, onHasChangesChange]); + + const handleSave = async () => { + if (!projectPath) return; // Guard against empty projectPath + setIsSaving(true); + try { + // Filter out empty secrets + const validSecrets = secrets.filter((s) => s.key.trim() !== "" && s.value.trim() !== ""); + await updateSecrets(projectPath, validSecrets); + setOriginalSecrets(validSecrets); + setSecrets(validSecrets); + setHasChanges(false); + // Notify parent so MCP section can update its known secret keys + onSecretsChanged?.(validSecrets.map((s) => s.key)); + } catch (err) { + console.error("Failed to save secrets:", err); + } finally { + setIsSaving(false); + } + }; + + const handleDiscard = () => { + setSecrets(originalSecrets); + setHasChanges(false); + }; + + const addSecret = () => { + setSecrets([...secrets, { key: "", value: "" }]); + }; + + const removeSecret = (index: number) => { + setSecrets(secrets.filter((_, i) => i !== index)); + const newVisible = new Set(visibleSecrets); + newVisible.delete(index); + setVisibleSecrets(newVisible); + }; + + const updateSecret = (index: number, field: "key" | "value", value: string) => { + const newSecrets = [...secrets]; + // Auto-capitalize key field for env variable convention + const processedValue = field === "key" ? value.toUpperCase() : value; + newSecrets[index] = { ...newSecrets[index], [field]: processedValue }; + setSecrets(newSecrets); + }; + + const toggleVisibility = (index: number) => { + const newVisible = new Set(visibleSecrets); + if (newVisible.has(index)) { + newVisible.delete(index); + } else { + newVisible.add(index); + } + setVisibleSecrets(newVisible); + }; + + // Import secrets from another project (doesn't overwrite existing keys). + // Uses a ref updated via useLayoutEffect to ensure the check happens synchronously + // before any state updates in the same render cycle. + const projectPathRef = React.useRef(projectPath); + React.useLayoutEffect(() => { + projectPathRef.current = projectPath; + }, [projectPath]); + + const handleImportFromProject = useCallback( + async (sourceProjectPath: string) => { + // Capture the project at invocation time + const targetProject = projectPathRef.current; + setIsImporting(true); + try { + const sourceSecrets = await getSecrets(sourceProjectPath); + // Cancel if project changed during the async fetch + if (projectPathRef.current !== targetProject) return; + if (sourceSecrets.length === 0) return; + + setSecrets((current) => { + const existingKeys = new Set(current.map((s) => s.key.toUpperCase())); + const newSecrets = sourceSecrets.filter((s) => !existingKeys.has(s.key.toUpperCase())); + return newSecrets.length > 0 ? [...current, ...newSecrets] : current; + }); + } catch (err) { + console.error("Failed to import secrets:", err); + } finally { + // Only clear importing if we're still on the same project + if (projectPathRef.current === targetProject) { + setIsImporting(false); + } + } + }, + [getSecrets] + ); + + // Expose test-only helpers to avoid flaky UI interactions in happy-dom. + useEffect(() => { + if (!isTestEnv || typeof window === "undefined") { + return; + } + + const testWindow = window as typeof window & { + __muxImportSecrets?: (path: string) => Promise; + __muxGetSecretsState?: () => Secret[]; + }; + testWindow.__muxImportSecrets = handleImportFromProject; + testWindow.__muxGetSecretsState = () => secrets; + return () => { + if (testWindow.__muxImportSecrets === handleImportFromProject) { + delete testWindow.__muxImportSecrets; + } + if (testWindow.__muxGetSecretsState) { + delete testWindow.__muxGetSecretsState; + } + }; + }, [handleImportFromProject, isTestEnv, secrets]); + + if (isLoading) { + return ( +
+ + Loading secrets… +
+ ); + } + + return ( +
+ {/* Toolbar with Add + Import */} +
+ + {otherProjects.length > 0 && + (isTestEnv ? ( +
+ {otherProjects.map(([path]) => { + const name = path.split("/").pop() ?? path; + return ( + + ); + })} +
+ ) : ( + + ))} + {/* Save/Discard buttons when there are changes */} + {hasChanges && ( +
+ + +
+ )} +
+ + {/* Secrets list */} + {secrets.length === 0 ? ( +

No secrets configured yet.

+ ) : ( +
+ {/* Column headers */} +
+ Key + Value +
+
+
+ {/* Secret rows */} + {secrets.map((secret, index) => ( +
+ updateSecret(index, "key", e.target.value)} + placeholder="SECRET_NAME" + disabled={isSaving} + className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-dim h-8 w-full rounded border px-2.5 font-mono text-xs text-white focus:outline-none" + /> + updateSecret(index, "value", e.target.value)} + placeholder="secret value" + disabled={isSaving} + className="bg-modal-bg border-border-medium focus:border-accent placeholder:text-dim h-8 w-full rounded border px-2.5 font-mono text-xs text-white focus:outline-none" + /> + + +
+ ))} +
+ )} + + {/* Info text */} +

+ Secrets are stored in ~/.mux/secrets.json and injected + as environment variables to agent tools. +

+
+ ); +}; diff --git a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx index 38518960fc..ab8c9979cf 100644 --- a/src/browser/components/Settings/sections/ProjectSettingsSection.tsx +++ b/src/browser/components/Settings/sections/ProjectSettingsSection.tsx @@ -42,6 +42,7 @@ import { } from "@/browser/utils/mcpHeaders"; import { ToolSelector } from "@/browser/components/ToolSelector"; import { KebabMenu, type KebabMenuItem } from "@/browser/components/KebabMenu"; +import { ProjectSecretsSection } from "./ProjectSecretsSection"; /** Component for managing tool allowlist for a single MCP server */ const ToolAllowlistSection: React.FC<{ @@ -744,8 +745,23 @@ export const ProjectSettingsSection: React.FC = () => { const [loading, setLoading] = useState(false); const [projectSecretKeys, setProjectSecretKeys] = useState([]); + const [secretsHaveChanges, setSecretsHaveChanges] = useState(false); const [error, setError] = useState(null); + // Wrap setSelectedProject to warn about unsaved secrets changes + const handleProjectChange = useCallback( + (newProject: string) => { + if (secretsHaveChanges) { + const confirmed = window.confirm( + "You have unsaved secret changes. Switch projects and discard changes?" + ); + if (!confirmed) return; + } + setSelectedProject(newProject); + }, + [secretsHaveChanges] + ); + // Test state with caching const { cache: testCache, @@ -821,7 +837,8 @@ export const ProjectSettingsSection: React.FC = () => { clearProjectsTargetProjectPath(); if (projectList.includes(target)) { - setSelectedProject(target); + // Use handleProjectChange to prompt for unsaved secrets + handleProjectChange(target); } else if (!selectedProject || !projectList.includes(selectedProject)) { setSelectedProject(projectList[0]); } @@ -832,7 +849,13 @@ export const ProjectSettingsSection: React.FC = () => { if (!selectedProject || !projectList.includes(selectedProject)) { setSelectedProject(projectList[0]); } - }, [projectList, selectedProject, projectsTargetProjectPath, clearProjectsTargetProjectPath]); + }, [ + projectList, + selectedProject, + projectsTargetProjectPath, + clearProjectsTargetProjectPath, + handleProjectChange, + ]); const refresh = useCallback(async () => { if (!api || !selectedProject) return; @@ -1190,7 +1213,7 @@ export const ProjectSettingsSection: React.FC = () => {
Project
Select a project to configure
- @@ -1205,6 +1228,16 @@ export const ProjectSettingsSection: React.FC = () => {
+ {/* Secrets */} +
+

Secrets

+ +
+ {/* MCP Servers */}

MCP Servers

diff --git a/src/browser/stories/App.projectSettings.stories.tsx b/src/browser/stories/App.projectSettings.stories.tsx index 6888902076..c1769b85b2 100644 --- a/src/browser/stories/App.projectSettings.stories.tsx +++ b/src/browser/stories/App.projectSettings.stories.tsx @@ -293,7 +293,10 @@ export const ProjectSettingsAddRemoteServerHeaders: AppStory = { await userEvent.type(textValueInput, "prod"); await expect(body.findByDisplayValue("Authorization")).resolves.toBeInTheDocument(); - await expect(body.findByDisplayValue("MCP_TOKEN")).resolves.toBeInTheDocument(); + // MCP_TOKEN appears in both the secrets section and the header value input, + // so use getAllByDisplayValue to verify at least one exists in headers + const mcpTokenInputs = body.getAllByDisplayValue("MCP_TOKEN"); + await expect(mcpTokenInputs.length).toBeGreaterThanOrEqual(1); await expect(body.findByDisplayValue("X-Env")).resolves.toBeInTheDocument(); await expect(body.findByDisplayValue("prod")).resolves.toBeInTheDocument(); }, diff --git a/src/browser/stories/App.sidebar.stories.tsx b/src/browser/stories/App.sidebar.stories.tsx index 1198e3f596..7a7c60d5cf 100644 --- a/src/browser/stories/App.sidebar.stories.tsx +++ b/src/browser/stories/App.sidebar.stories.tsx @@ -684,3 +684,88 @@ export const WorkspaceDraftSelected: AppStory = { await userEvent.click(row); }, }; + +/** + * Project settings with secrets management. + * Shows the Settings modal → Projects section with secrets for multiple projects. + * Uses play function to open via the key icon button on a project header (now opens Settings → Projects). + */ +export const ProjectSettingsWithSecrets: AppStory = { + render: () => ( + { + const sourceProjectPath = "/home/user/projects/source-project"; + const targetProjectPath = "/home/user/projects/target-project"; + + const sourceWorkspace = createWorkspace({ + id: "ws-source", + name: "source-branch", + title: "Source workspace", + projectName: "source-project", + projectPath: sourceProjectPath, + createdAt: new Date(NOW - 86400000).toISOString(), + }); + + const targetWorkspace = createWorkspace({ + id: "ws-target", + name: "target-branch", + title: "Target workspace", + projectName: "target-project", + projectPath: targetProjectPath, + createdAt: new Date(NOW - 3600000).toISOString(), + }); + + const workspaces = [sourceWorkspace, targetWorkspace]; + + // Set up secrets for both projects + const projectSecrets = new Map>(); + projectSecrets.set(sourceProjectPath, [ + { key: "SOURCE_API_KEY", value: "sk-source-12345" }, + { key: "SHARED_TOKEN", value: "source-shared-value" }, + { key: "SOURCE_SECRET", value: "source-only-secret" }, + ]); + projectSecrets.set(targetProjectPath, [ + { key: "SHARED_TOKEN", value: "target-shared-value" }, + { key: "TARGET_DB_URL", value: "postgres://target:5432/db" }, + ]); + + expandProjects([sourceProjectPath, targetProjectPath]); + + return createMockORPCClient({ + projects: groupWorkspacesByProject(workspaces), + workspaces, + projectSecrets, + }); + }} + /> + ), + play: async ({ canvasElement }) => { + // Wait for the projects to load + await waitFor( + () => { + const settingsButton = canvasElement.querySelector( + '[aria-label="Configure target-project"]' + ); + if (!settingsButton) throw new Error("Project settings button not found"); + }, + { timeout: 5000 } + ); + + // Click the key icon for target-project (now opens Settings → Projects) + const settingsButton = canvasElement.querySelector( + '[aria-label="Configure target-project"]' + )!; + await userEvent.click(settingsButton); + + // Wait for Settings modal to open (portaled to body) + await waitFor( + () => { + const modal = document.body.querySelector('[role="dialog"]'); + if (!modal) throw new Error("Settings modal not found"); + // Should show Projects section with Secrets heading + within(modal).getByText("Secrets"); + }, + { timeout: 5000 } + ); + }, +}; diff --git a/tests/setup.ts b/tests/setup.ts index 9ac24cc345..470c9967b7 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -12,7 +12,7 @@ assert.equal(typeof Symbol.dispose, "symbol"); // Many renderer components gate test-only behavior on `import.meta.env.MODE === "test"`. // In Jest, `import.meta.env` is rewritten to `process.env` by our Babel plugin. -process.env.MODE ??= "test"; +process.env.MODE = "test"; if (process.env.MUX_FORCE_REAL_TOKENIZER !== "1") { process.env.MUX_APPROX_TOKENIZER ??= "1"; } diff --git a/tests/ui/secretsImport.integration.test.ts b/tests/ui/secretsImport.integration.test.ts new file mode 100644 index 0000000000..83009b41c6 --- /dev/null +++ b/tests/ui/secretsImport.integration.test.ts @@ -0,0 +1,217 @@ +/** + * UI integration tests for the secrets import feature. + * + * Tests that secrets can be imported from one project to another via Settings → Projects, + * and that existing secrets are not overwritten. + */ + +import "./dom"; +import { act, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { shouldRunIntegrationTests } from "../testUtils"; +import { createTestEnvironment, cleanupTestEnvironment, preloadTestModules } from "../ipc/setup"; +import { cleanupTempGitRepo, createTempGitRepo } from "../ipc/helpers"; + +import { renderApp } from "./renderReviewPanel"; +import { cleanupView, setupTestDom } from "./helpers"; + +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +describeIntegration("Secrets Import (UI)", () => { + beforeAll(async () => { + await preloadTestModules(); + }); + + test("imports secrets from another project without overwriting existing keys", async () => { + const env = await createTestEnvironment(); + const sourceRepoPath = await createTempGitRepo(); + const targetRepoPath = await createTempGitRepo(); + const cleanupDom = setupTestDom(); + + let view: ReturnType | undefined; + + try { + // Create source project with secrets + await env.orpc.projects.create({ projectPath: sourceRepoPath }); + await env.orpc.projects.secrets.update({ + projectPath: sourceRepoPath, + secrets: [ + { key: "SOURCE_KEY_1", value: "source_value_1" }, + { key: "SHARED_KEY", value: "source_shared_value" }, + { key: "SOURCE_KEY_2", value: "source_value_2" }, + ], + }); + + // Create target project with one existing secret that overlaps + await env.orpc.projects.create({ projectPath: targetRepoPath }); + await env.orpc.projects.secrets.update({ + projectPath: targetRepoPath, + secrets: [ + { key: "SHARED_KEY", value: "target_shared_value" }, // This should NOT be overwritten + { key: "TARGET_KEY_1", value: "target_value_1" }, + ], + }); + + await waitFor( + async () => { + const sourceSecrets = await env.orpc.projects.secrets.get({ + projectPath: sourceRepoPath, + }); + expect(sourceSecrets.length).toBe(3); + }, + { timeout: 5_000 } + ); + await waitFor( + async () => { + const targetSecrets = await env.orpc.projects.secrets.get({ + projectPath: targetRepoPath, + }); + expect(targetSecrets.length).toBe(2); + }, + { timeout: 5_000 } + ); + + // Render the app + view = renderApp({ apiClient: env.orpc }); + await view.waitForReady(); + + // Wait for the sidebar to show projects + const targetProjectName = targetRepoPath.split("/").pop()!; + await waitFor( + () => { + const sidebar = view!.container.querySelector('[aria-label="Projects"]'); + if (!sidebar) throw new Error("Project sidebar not found"); + // Check that projects are loaded - look for the project settings button (key icon) + const settingsButton = view!.container.querySelector( + `[aria-label="Configure ${targetProjectName}"]` + ); + if (!settingsButton) + throw new Error( + `Project settings button for target project not found: ${targetProjectName}` + ); + }, + { timeout: 10_000 } + ); + + // Open Settings → Projects for target project by clicking the key icon + const settingsButton = view!.container.querySelector( + `[aria-label="Configure ${targetProjectName}"]` + ) as HTMLElement; + await userEvent.click(settingsButton); + + // Wait for Settings modal to open - query document.body since Radix uses portals + await waitFor( + () => { + const modal = document.body.querySelector('[role="dialog"]'); + if (!modal) throw new Error("Settings modal not found"); + // Should be in Projects section with Secrets heading + within(modal as HTMLElement).getByText("Secrets"); + }, + { timeout: 5_000 } + ); + + const modal = document.body.querySelector('[role="dialog"]') as HTMLElement; + + // Verify initial secrets are shown (2 secrets: SHARED_KEY and TARGET_KEY_1) + await waitFor( + () => { + const keyInputs = modal.querySelectorAll('input[placeholder="SECRET_NAME"]'); + expect(keyInputs.length).toBe(2); + }, + { timeout: 5_000 } + ); + + // Find and use the import control. + const testWindow = window as typeof window & { + __muxImportSecrets?: (path: string) => Promise; + __muxGetSecretsState?: () => Array<{ key: string; value: string }>; + }; + let importHelper: ((path: string) => Promise) | undefined; + + await waitFor( + () => { + importHelper = testWindow.__muxImportSecrets; + if (!importHelper) { + throw new Error("Import helper not ready"); + } + }, + { timeout: 5_000 } + ); + + await act(async () => { + await importHelper?.(sourceRepoPath); + }); + + // Wait for import to complete - should now have 4 secrets + // (TARGET_KEY_1, SHARED_KEY from target + SOURCE_KEY_1, SOURCE_KEY_2 from source) + await waitFor( + () => { + const secretsState = testWindow.__muxGetSecretsState?.(); + if (!secretsState) { + throw new Error("Secrets state helper not ready"); + } + expect(secretsState.length).toBe(4); + const keyInputs = modal.querySelectorAll('input[placeholder="SECRET_NAME"]'); + expect(keyInputs.length).toBe(4); + }, + { timeout: 20_000 } + ); + + // Verify the keys are correct + const keyInputs = modal.querySelectorAll( + 'input[placeholder="SECRET_NAME"]' + ) as NodeListOf; + const keys = Array.from(keyInputs).map((input) => input.value); + + // Original target secrets should be first + expect(keys).toContain("SHARED_KEY"); + expect(keys).toContain("TARGET_KEY_1"); + // Imported secrets added + expect(keys).toContain("SOURCE_KEY_1"); + expect(keys).toContain("SOURCE_KEY_2"); + + // Verify SHARED_KEY was NOT overwritten - find its value input + const sharedKeyIndex = keys.indexOf("SHARED_KEY"); + const valueInputs = modal.querySelectorAll( + 'input[placeholder="secret value"]' + ) as NodeListOf; + // The value should still be the target's value, not source's + expect(valueInputs[sharedKeyIndex].value).toBe("target_shared_value"); + + const secretsState = testWindow.__muxGetSecretsState?.(); + if (!secretsState) { + throw new Error("Secrets state helper not ready"); + } + await env.orpc.projects.secrets.update({ + projectPath: targetRepoPath, + secrets: secretsState, + }); + + // Verify secrets were saved correctly via API + const savedSecrets = await env.orpc.projects.secrets.get({ projectPath: targetRepoPath }); + expect(savedSecrets.length).toBe(4); + + const savedKeys = savedSecrets.map((s) => s.key); + expect(savedKeys).toContain("SHARED_KEY"); + expect(savedKeys).toContain("TARGET_KEY_1"); + expect(savedKeys).toContain("SOURCE_KEY_1"); + expect(savedKeys).toContain("SOURCE_KEY_2"); + + // Verify SHARED_KEY value was preserved + const sharedSecret = savedSecrets.find((s) => s.key === "SHARED_KEY"); + expect(sharedSecret?.value).toBe("target_shared_value"); + } finally { + if (view) { + await cleanupView(view, cleanupDom); + } else { + cleanupDom(); + } + await env.orpc.projects.remove({ projectPath: sourceRepoPath }); + await env.orpc.projects.remove({ projectPath: targetRepoPath }); + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(sourceRepoPath); + await cleanupTempGitRepo(targetRepoPath); + } + }, 60_000); +});