import { describe, it, expect, beforeEach, vi } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useStackActions } from './useStackActions'; import type { useEditorViewState } from './useEditorViewState'; import type { useStackListState } from './useStackListState'; import type { useViewNavigationState } from './useViewNavigationState'; import type { OverlayState } from './useOverlayState'; vi.mock('@/lib/api', () => ({ apiFetch: vi.fn(), DEPLOY_SESSION_HEADER: 'x-deploy-session-id', withDeploySession: (deploySessionId: string, options: RequestInit = {}) => ({ ...options, headers: { ...(options.headers as Record | undefined), 'x-deploy-session-id': deploySessionId }, }), })); vi.mock('@/components/ui/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() }, })); import { apiFetch } from '@/lib/api'; type EditorState = ReturnType; type StackListState = ReturnType; type NavState = ReturnType; function makeEditorState(over: Partial = {}): EditorState { const base = { content: 'services: {}', originalContent: 'services: {}\n', envContent: '', originalEnvContent: '', activeTab: 'compose' as const, selectedEnvFile: '', composeEtag: null as string | null, envEtag: null as string | null, setContent: vi.fn(), setOriginalContent: vi.fn(), setEnvContent: vi.fn(), setOriginalEnvContent: vi.fn(), setIsEditing: vi.fn(), setEditingCompose: vi.fn(), setActiveTab: vi.fn(), setContainers: vi.fn(), setEnvFiles: vi.fn(), setSelectedEnvFile: vi.fn(), setEnvExists: vi.fn(), setBackupInfo: vi.fn(), setIsFileLoading: vi.fn(), setGitSourcePendingMap: vi.fn(), setComposeEtag: vi.fn(), setEnvEtag: vi.fn(), effectiveServices: [], setEffectiveServices: vi.fn(), serviceUpdateInProgress: null, setServiceUpdateInProgress: vi.fn(), }; return { ...base, ...over } as unknown as EditorState; } function makeStackListState(over: Partial = {}): StackListState { const base = { selectedFile: 'web.yml', files: ['web.yml'], stackStatuses: { 'web.yml': 'running' }, stackPorts: {}, stackSelfFlags: {}, setSelectedFile: vi.fn(), setOptimisticStatus: vi.fn(), setStackAction: vi.fn(), clearStackAction: vi.fn(), isStackBusy: vi.fn().mockReturnValue(false), refreshStacks: vi.fn(), setSearchQuery: vi.fn(), fetchImageUpdates: vi.fn(), lastActionResult: {}, recordActionFailure: vi.fn(), recordActionSuccess: vi.fn(), clearActionRecords: vi.fn(), dismissActionResult: vi.fn(), }; return { ...base, ...over } as unknown as StackListState; } function makeOverlay(over: Partial = {}): OverlayState { return { setPendingUnsavedLoad: vi.fn(), setPendingLoadOptions: vi.fn(), setPendingUnsavedNode: vi.fn(), setPendingLeaveAction: vi.fn(), pendingUnsavedLoad: null, pendingLoadOptions: null, pendingUnsavedNode: null, pendingLeaveAction: null, policyBlock: null, setPolicyBlock: vi.fn(), setPolicyBypassing: vi.fn(), updateReadiness: null, setUpdateReadiness: vi.fn(), preDeployAdvisory: null, setPreDeployAdvisory: vi.fn(), openSelfStackProtected: vi.fn(), setDiffPreview: vi.fn(), ...over, } as unknown as OverlayState; } let lastRunWithLogParams: Parameters[0]['runWithLog']>[0] | null = null; const runWithLog: Parameters[0]['runWithLog'] = async (params, run) => { lastRunWithLogParams = params; return run(Promise.resolve(), 'test-session'); }; function setup(over: { editorState?: Partial; overlay?: Partial; stackList?: Partial; getLastDeployOutputLine?: (stackName: string) => string | undefined; hasUpdateGuard?: boolean; canEditStack?: (stackNameOrFilename: string) => boolean; activeNode?: Parameters[0]['activeNode']; setActiveNode?: Parameters[0]['setActiveNode']; } = {}) { const editorState = makeEditorState(over.editorState); const stackListState = makeStackListState(over.stackList); const navState = { setActiveView: vi.fn() } as unknown as NavState; const overlayState = makeOverlay(over.overlay); const setActiveNode = over.setActiveNode ?? vi.fn(); const { result } = renderHook(() => useStackActions({ editorState, stackListState, navState, overlayState, activeNode: over.activeNode ?? ({ id: 1, type: 'local' } as Parameters[0]['activeNode']), setActiveNode, nodes: [], runWithLog, getLastDeployOutputLine: over.getLastDeployOutputLine ?? (() => undefined), diffPreviewEnabled: false, hasUpdateGuard: over.hasUpdateGuard ?? false, canEditStack: over.canEditStack ?? (() => true), }), ); return { result, editorState, stackListState, overlayState, setActiveNode }; } describe('useStackActions.saveFile', () => { beforeEach(() => { vi.mocked(apiFetch).mockReset(); }); it('returns true and stores originalContent when the PUT succeeds', async () => { vi.mocked(apiFetch).mockResolvedValue(new Response(null, { status: 200 })); const { result, editorState } = setup({ editorState: { content: 'new', originalContent: 'old' }, }); const ok = await result.current.saveFile(); expect(ok).toBe(true); expect(editorState.setOriginalContent).toHaveBeenCalledWith('new'); // Stay editable after save (no re-gate into read-only Monaco). expect(editorState.setIsEditing).not.toHaveBeenCalledWith(false); }); it('returns false and leaves dirty state intact when the PUT fails', async () => { vi.mocked(apiFetch).mockResolvedValue(new Response('boom', { status: 500 })); const { result, editorState } = setup({ editorState: { content: 'new', originalContent: 'old' }, }); const ok = await result.current.saveFile(); expect(ok).toBe(false); expect(editorState.setOriginalContent).not.toHaveBeenCalled(); }); it('returns false when no stack is selected', async () => { vi.mocked(apiFetch).mockResolvedValue(new Response(null, { status: 200 })); const editorState = makeEditorState(); const stackListState = makeStackListState({ selectedFile: null }); const { result } = renderHook(() => useStackActions({ editorState, stackListState, navState: { setActiveView: vi.fn() } as unknown as NavState, overlayState: makeOverlay(), activeNode: { id: 1, type: 'local' } as Parameters[0]['activeNode'], setActiveNode: vi.fn(), nodes: [], runWithLog, getLastDeployOutputLine: () => undefined, diffPreviewEnabled: false, canEditStack: () => true, }), ); const ok = await result.current.saveFile(); expect(ok).toBe(false); expect(apiFetch).not.toHaveBeenCalled(); }); }); describe('useStackActions.handleSaveAndDeploy', () => { beforeEach(() => { vi.mocked(apiFetch).mockReset(); }); it('does NOT call /deploy when saveFile fails', async () => { // PUT returns 500 → saveFile resolves false → deploy must not be attempted. vi.mocked(apiFetch).mockResolvedValueOnce(new Response('save broke', { status: 500 })); const { result } = setup(); await result.current.handleSaveAndDeploy({ preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent); const calls = vi.mocked(apiFetch).mock.calls.map(c => c[0]); expect(calls.some(c => String(c).includes('/deploy'))).toBe(false); }); it('calls /deploy when saveFile succeeds', async () => { vi.mocked(apiFetch).mockResolvedValueOnce(new Response(null, { status: 200 })); // save OK vi.mocked(apiFetch).mockResolvedValueOnce(new Response(null, { status: 200 })); // deploy OK vi.mocked(apiFetch).mockResolvedValueOnce(new Response('[]', { status: 200 })); // containers refresh const { result } = setup(); await result.current.handleSaveAndDeploy({ preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent); const calls = vi.mocked(apiFetch).mock.calls.map(c => c[0]); expect(calls.some(c => String(c).includes('/deploy'))).toBe(true); }); }); describe('useStackActions node binding', () => { const mouseEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent; beforeEach(() => { vi.mocked(apiFetch).mockReset(); vi.mocked(apiFetch).mockResolvedValue(new Response(null, { status: 200 })); lastRunWithLogParams = null; }); function postCallFor(fragment: string): RequestInit | undefined { const call = vi.mocked(apiFetch).mock.calls.find( c => String(c[0]).includes(fragment) && (c[1] as RequestInit | undefined)?.method === 'POST', ); return call?.[1] as RequestInit | undefined; } it('binds the deploy POST and the runWithLog session to the captured node', async () => { const { result } = setup(); // activeNode.id = 1 await result.current.deployStack(mouseEvent); expect(postCallFor('/deploy')).toEqual(expect.objectContaining({ nodeId: 1 })); expect(lastRunWithLogParams).toEqual(expect.objectContaining({ nodeId: 1 })); }); it('binds the update POST and the runWithLog session to the captured node', async () => { const { result } = setup(); await result.current.updateStack(mouseEvent); expect(postCallFor('/update')).toEqual(expect.objectContaining({ nodeId: 1 })); expect(lastRunWithLogParams).toEqual(expect.objectContaining({ nodeId: 1 })); }); it('binds a non-update action (restart) POST and session to the captured node', async () => { const { result } = setup(); await result.current.restartStack(mouseEvent); expect(postCallFor('/restart')).toEqual(expect.objectContaining({ nodeId: 1 })); expect(lastRunWithLogParams).toEqual(expect.objectContaining({ nodeId: 1 })); }); }); describe('useStackActions policy-block dialog wiring', () => { const policyPayload = { error: 'Policy "block-high" blocked deploy: 1 image(s) exceed HIGH', policy: { id: 1, name: 'block-high', maxSeverity: 'HIGH' }, violations: [{ imageRef: 'nginx:1.14', severity: 'CRITICAL', criticalCount: 2, highCount: 5, scanId: 9 }], }; const mouseEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent; beforeEach(() => { vi.mocked(apiFetch).mockReset(); }); it('opens the dialog with action "deploy" when an editor deploy is blocked', async () => { // deployStack first fetches the pre-deploy advisory summary; keep it off so // the flow reaches the deploy POST that returns the policy block. vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify({ enabled: false }), { status: 200 })); vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify(policyPayload), { status: 409 })); const { result, overlayState } = setup(); await result.current.deployStack(mouseEvent); expect(overlayState.setPolicyBlock).toHaveBeenCalledWith( expect.objectContaining({ stackName: 'web', stackFile: 'web.yml', action: 'deploy' }), ); }); it('opens the dialog with action "update" when an update is blocked', async () => { vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify(policyPayload), { status: 409 })); const { result, overlayState } = setup(); await result.current.updateStack(mouseEvent); expect(overlayState.setPolicyBlock).toHaveBeenCalledWith( expect.objectContaining({ stackName: 'web', stackFile: 'web.yml', action: 'update' }), ); }); it('opens the dialog with action "deploy" when a sidebar deploy is blocked', async () => { vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify(policyPayload), { status: 409 })); const { result, overlayState } = setup(); await result.current.executeStackActionByFile('web.yml', 'deploy', 'deploy'); expect(overlayState.setPolicyBlock).toHaveBeenCalledWith( expect.objectContaining({ stackName: 'web', stackFile: 'web.yml', action: 'deploy' }), ); }); it('does not open the dialog for a stack-op-in-progress 409', async () => { const inProgress = JSON.stringify({ code: 'stack_op_in_progress', inProgress: { action: 'deploy', startedAt: Date.now(), user: 'someone' }, }); vi.mocked(apiFetch).mockResolvedValueOnce(new Response(inProgress, { status: 409 })); const { result, overlayState } = setup(); await result.current.updateStack(mouseEvent); expect(overlayState.setPolicyBlock).not.toHaveBeenCalled(); }); it('opens the dialog with action "update" via the sidebar update entry point', async () => { vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify(policyPayload), { status: 409 })); const { result, overlayState } = setup(); await result.current.executeStackActionByFile('web.yml', 'update', 'update'); expect(overlayState.setPolicyBlock).toHaveBeenCalledWith( expect.objectContaining({ stackName: 'web', stackFile: 'web.yml', action: 'update' }), ); }); it('opens the dialog with action "rollback" when a rollback is blocked', async () => { vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify(policyPayload), { status: 409 })); const { result, overlayState } = setup(); await result.current.rollbackStack(); expect(overlayState.setPolicyBlock).toHaveBeenCalledWith( expect.objectContaining({ stackName: 'web', stackFile: 'web.yml', action: 'rollback' }), ); }); }); describe('useStackActions pre-deploy advisory', () => { const mouseEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent; beforeEach(() => { vi.mocked(apiFetch).mockReset(); lastRunWithLogParams = null; }); it('opens the advisory before deploying and defers the deploy until the user proceeds', async () => { const setPreDeployAdvisory = vi.fn(); vi.mocked(apiFetch).mockImplementation(async (url: string) => { if (String(url).includes('/pre-deploy-summary')) { return new Response(JSON.stringify({ enabled: true, images: [{ imageRef: 'nginx:1.14', scan: null }] }), { status: 200 }); } return new Response(null, { status: 200 }); }); const { result } = setup({ overlay: { setPreDeployAdvisory } }); await result.current.deployStack(mouseEvent); // The advisory opened and the deploy has NOT started (no progress log, no POST). expect(setPreDeployAdvisory).toHaveBeenCalledWith( expect.objectContaining({ stackName: 'web', images: [{ imageRef: 'nginx:1.14', scan: null }] }), ); expect(lastRunWithLogParams).toBeNull(); expect(vi.mocked(apiFetch).mock.calls.filter(c => String(c[0]).includes('/deploy'))).toHaveLength(0); // Proceeding runs the actual deploy. const arg = setPreDeployAdvisory.mock.calls[0][0] as { proceed: () => void }; arg.proceed(); await vi.waitFor(() => expect(lastRunWithLogParams).not.toBeNull()); }); it('deploys directly, with no advisory, when the summary is disabled', async () => { const setPreDeployAdvisory = vi.fn(); vi.mocked(apiFetch).mockResolvedValue(new Response(JSON.stringify({ enabled: false }), { status: 200 })); const { result } = setup({ overlay: { setPreDeployAdvisory } }); await result.current.deployStack(mouseEvent); expect(setPreDeployAdvisory).not.toHaveBeenCalled(); expect(lastRunWithLogParams).not.toBeNull(); }); it('deploys directly when the advisory is enabled but no images are returned', async () => { const setPreDeployAdvisory = vi.fn(); vi.mocked(apiFetch).mockImplementation(async (url: string) => { if (String(url).includes('/pre-deploy-summary')) { return new Response(JSON.stringify({ enabled: true, images: [] }), { status: 200 }); } return new Response(null, { status: 200 }); }); const { result } = setup({ overlay: { setPreDeployAdvisory } }); await result.current.deployStack(mouseEvent); expect(setPreDeployAdvisory).not.toHaveBeenCalled(); expect(lastRunWithLogParams).not.toBeNull(); }); it('binds the advisory fetch and the deferred deploy to the captured node', async () => { const setPreDeployAdvisory = vi.fn(); vi.mocked(apiFetch).mockImplementation(async (url: string) => { if (String(url).includes('/pre-deploy-summary')) { return new Response(JSON.stringify({ enabled: true, images: [{ imageRef: 'nginx:1.14', scan: null }] }), { status: 200 }); } return new Response(null, { status: 200 }); }); const { result } = setup({ overlay: { setPreDeployAdvisory }, activeNode: { id: 42, type: 'local' } as Parameters[0]['activeNode'], }); await result.current.deployStack(mouseEvent); expect(apiFetch).toHaveBeenCalledWith( expect.stringContaining('/pre-deploy-summary'), expect.objectContaining({ nodeId: 42 }), ); const arg = (setPreDeployAdvisory.mock.calls[0][0]) as { proceed: () => void }; arg.proceed(); await vi.waitFor(() => expect(lastRunWithLogParams?.nodeId).toBe(42)); }); it('runs the deploy at most once even if proceed fires twice', async () => { const setPreDeployAdvisory = vi.fn(); vi.mocked(apiFetch).mockImplementation(async (url: string) => { if (String(url).includes('/pre-deploy-summary')) { return new Response(JSON.stringify({ enabled: true, images: [{ imageRef: 'nginx:1.14', scan: null }] }), { status: 200 }); } return new Response(null, { status: 200 }); }); const { result } = setup({ overlay: { setPreDeployAdvisory } }); await result.current.deployStack(mouseEvent); const arg = (setPreDeployAdvisory.mock.calls[0][0]) as { proceed: () => void }; arg.proceed(); await vi.waitFor(() => expect(lastRunWithLogParams).not.toBeNull()); lastRunWithLogParams = null; arg.proceed(); await Promise.resolve(); expect(lastRunWithLogParams).toBeNull(); }); it('blocks a second deploy click while the advisory fetch is still pending', async () => { const setPreDeployAdvisory = vi.fn(); const summaryGate: { release: () => void } = { release: () => {} }; vi.mocked(apiFetch).mockImplementation((url: string) => { if (String(url).includes('/pre-deploy-summary')) { return new Promise((resolve) => { summaryGate.release = () => resolve(new Response(JSON.stringify({ enabled: false }), { status: 200 })); }); } return Promise.resolve(new Response(null, { status: 200 })); }); const { result } = setup({ overlay: { setPreDeployAdvisory } }); const first = result.current.deployStack(mouseEvent); const second = result.current.deployStack(mouseEvent); // double-click while the summary is in flight summaryGate.release(); await Promise.all([first, second]); await vi.waitFor(() => expect(lastRunWithLogParams).not.toBeNull()); // The second click is blocked before it ever issues a request, so exactly // one summary fetch and one deploy occur. const summaryCalls = vi.mocked(apiFetch).mock.calls.filter(c => String(c[0]).includes('/pre-deploy-summary')); expect(summaryCalls).toHaveLength(1); }); it('clears the pending guard on cancel so a later deploy is allowed', async () => { const setPreDeployAdvisory = vi.fn(); vi.mocked(apiFetch).mockImplementation(async (url: string) => { if (String(url).includes('/pre-deploy-summary')) { return new Response(JSON.stringify({ enabled: true, images: [{ imageRef: 'nginx:1.14', scan: null }] }), { status: 200 }); } return new Response(null, { status: 200 }); }); const { result } = setup({ overlay: { setPreDeployAdvisory } }); await result.current.deployStack(mouseEvent); const arg = (setPreDeployAdvisory.mock.calls[0][0]) as { cancel: () => void }; arg.cancel(); // dismiss the advisory; the guard must release await result.current.deployStack(mouseEvent); // a fresh deploy must not be blocked // Calls: open, null (cancel), open again. The third proves the guard cleared // and the later deploy opened a fresh advisory rather than being blocked. expect(setPreDeployAdvisory).toHaveBeenCalledTimes(3); expect(setPreDeployAdvisory.mock.calls[2][0]).toMatchObject({ stackName: 'web' }); }); }); describe('useStackActions.bypassPolicyAndRetry', () => { const payload = { error: 'blocked', policy: { id: 1, name: 'block-high', maxSeverity: 'HIGH' }, violations: [{ imageRef: 'nginx:1.14', severity: 'CRITICAL', criticalCount: 1, highCount: 0, scanId: 1 }], }; beforeEach(() => { vi.mocked(apiFetch).mockReset(); }); it('retries an update bypass against the update endpoint with ?ignorePolicy=true', async () => { vi.mocked(apiFetch).mockResolvedValueOnce(new Response(null, { status: 200 })); // update OK vi.mocked(apiFetch).mockResolvedValueOnce(new Response('[]', { status: 200 })); // containers refresh const { result } = setup({ overlay: { policyBlock: { stackName: 'web', stackFile: 'web.yml', action: 'update', payload } as never }, }); await result.current.bypassPolicyAndRetry(); const urls = vi.mocked(apiFetch).mock.calls.map(c => String(c[0])); expect(urls).toContain('/stacks/web/update?ignorePolicy=true'); expect(urls.some(u => u.includes('/deploy'))).toBe(false); }); it('retries a deploy bypass against the deploy endpoint with ?ignorePolicy=true', async () => { vi.mocked(apiFetch).mockResolvedValueOnce(new Response(null, { status: 200 })); // deploy OK vi.mocked(apiFetch).mockResolvedValueOnce(new Response('[]', { status: 200 })); // containers refresh const { result } = setup({ overlay: { policyBlock: { stackName: 'web', stackFile: 'web.yml', action: 'deploy', payload } as never }, }); await result.current.bypassPolicyAndRetry(); const urls = vi.mocked(apiFetch).mock.calls.map(c => String(c[0])); expect(urls).toContain('/stacks/web/deploy?ignorePolicy=true'); expect(urls.some(u => u.includes('/update'))).toBe(false); }); it('retries a rollback bypass against the rollback endpoint with ?ignorePolicy=true', async () => { vi.mocked(apiFetch).mockResolvedValueOnce(new Response(null, { status: 200 })); // rollback OK vi.mocked(apiFetch).mockResolvedValueOnce(new Response('content', { status: 200 })); // content reload vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify({ exists: true }), { status: 200 })); // backup info const { result } = setup({ overlay: { policyBlock: { stackName: 'web', stackFile: 'web.yml', action: 'rollback', payload } as never }, }); await result.current.bypassPolicyAndRetry(); const urls = vi.mocked(apiFetch).mock.calls.map(c => String(c[0])); expect(urls).toContain('/stacks/web.yml/rollback?ignorePolicy=true'); }); it('retries on the node captured in the policy block, not the live active node', async () => { vi.mocked(apiFetch).mockResolvedValueOnce(new Response(null, { status: 200 })); // update OK vi.mocked(apiFetch).mockResolvedValueOnce(new Response('[]', { status: 200 })); // containers refresh const { result } = setup({ activeNode: { id: 1, type: 'local' } as never, // active node has since moved to 1 overlay: { policyBlock: { stackName: 'web', stackFile: 'web.yml', action: 'update', payload, nodeId: 9 } as never }, }); await result.current.bypassPolicyAndRetry(); const updateCall = vi.mocked(apiFetch).mock.calls.find(c => String(c[0]).includes('/update?ignorePolicy=true')); expect(updateCall).toBeDefined(); expect((updateCall![1] as { nodeId?: number | null }).nodeId).toBe(9); }); it('does nothing when no policy block is stored', async () => { const { result } = setup({ overlay: { policyBlock: null as never } }); await result.current.bypassPolicyAndRetry(); expect(apiFetch).not.toHaveBeenCalled(); }); }); describe('useStackActions.attemptLeaveEditor (mobile back / nav guard)', () => { beforeEach(() => { vi.mocked(apiFetch).mockReset(); }); it('stashes the navigation when the editor is dirty instead of running it', () => { const perform = vi.fn(); // Default fixture: content !== originalContent and a stack is selected → dirty. const { result, overlayState } = setup(); result.current.attemptLeaveEditor(perform); expect(perform).not.toHaveBeenCalled(); expect(overlayState.setPendingLeaveAction).toHaveBeenCalledWith({ run: perform }); }); it('runs the navigation immediately when the editor is clean', () => { const perform = vi.fn(); const { result, overlayState } = setup({ editorState: { content: 'same', originalContent: 'same' }, }); result.current.attemptLeaveEditor(perform); expect(perform).toHaveBeenCalledTimes(1); expect(overlayState.setPendingLeaveAction).not.toHaveBeenCalled(); }); it('runs the stashed leave action and clears it on discardAndLoadPending', () => { const run = vi.fn(); const { result, overlayState, editorState } = setup({ overlay: { pendingLeaveAction: { run } } }); result.current.discardAndLoadPending(); expect(run).toHaveBeenCalledTimes(1); expect(overlayState.setPendingLeaveAction).toHaveBeenCalledWith(null); expect(editorState.setContent).toHaveBeenCalledWith(editorState.originalContent); }); it('gives a stashed leave action precedence over a coexisting pending load', () => { const run = vi.fn(); const { result } = setup({ overlay: { pendingLeaveAction: { run }, pendingUnsavedLoad: 'other.yml' } }); result.current.discardAndLoadPending(); expect(run).toHaveBeenCalledTimes(1); // The leave branch returns before the load branch, so no stack fetch fires. expect(apiFetch).not.toHaveBeenCalled(); }); it('clears a stashed leave action on cancel', () => { const { result, overlayState } = setup({ overlay: { pendingLeaveAction: { run: vi.fn() } } }); result.current.cancelPendingUnsavedLoad(); expect(overlayState.setPendingLeaveAction).toHaveBeenCalledWith(null); expect(overlayState.setPendingLoadOptions).toHaveBeenCalledWith(null); }); }); describe('useStackActions open/close compose editor', () => { beforeEach(() => { vi.mocked(apiFetch).mockReset(); }); it('openComposeEditor sets editingCompose, compose tab, and isEditing when permitted', () => { const { result, editorState } = setup(); result.current.openComposeEditor(); expect(editorState.setEditingCompose).toHaveBeenCalledWith(true); expect(editorState.setActiveTab).toHaveBeenCalledWith('compose'); expect(editorState.setIsEditing).toHaveBeenCalledWith(true); }); it('openComposeEditor is a no-op when canEditStack returns false', () => { const { result, editorState } = setup({ canEditStack: () => false }); result.current.openComposeEditor(); expect(editorState.setEditingCompose).not.toHaveBeenCalled(); }); it('closeComposeEditor reverts both buffers when .env is dirty and compose tab is active', () => { const { result, editorState } = setup({ editorState: { activeTab: 'compose', content: 'compose-clean', originalContent: 'compose-clean', envContent: 'env-dirty', originalEnvContent: 'env-clean', }, }); result.current.closeComposeEditor(); expect(editorState.setContent).toHaveBeenCalledWith('compose-clean'); expect(editorState.setEnvContent).toHaveBeenCalledWith('env-clean'); expect(editorState.setEditingCompose).toHaveBeenCalledWith(false); }); it('closeComposeEditor reverts both buffers when compose is dirty and files tab is active', () => { const { result, editorState } = setup({ editorState: { activeTab: 'files', content: 'compose-dirty', originalContent: 'compose-clean', envContent: 'env-clean', originalEnvContent: 'env-clean', }, }); result.current.closeComposeEditor(); expect(editorState.setContent).toHaveBeenCalledWith('compose-clean'); expect(editorState.setEnvContent).toHaveBeenCalledWith('env-clean'); expect(editorState.setEditingCompose).toHaveBeenCalledWith(false); }); }); describe('useStackActions loadFile startInComposeEdit + pending options', () => { beforeEach(() => { vi.mocked(apiFetch).mockReset(); }); it('defers a dirty load and stores pendingLoadOptions', async () => { const { result, overlayState } = setup({ editorState: { content: 'dirty', originalContent: 'clean' }, stackList: { selectedFile: 'web.yml' }, }); await result.current.loadFile('new.yml', { startInComposeEdit: true }); expect(overlayState.setPendingUnsavedLoad).toHaveBeenCalledWith('new.yml'); expect(overlayState.setPendingLoadOptions).toHaveBeenCalledWith({ startInComposeEdit: true }); expect(apiFetch).not.toHaveBeenCalled(); }); it('opens compose edit after load when startInComposeEdit and canEditStack(target) both pass', async () => { const canEditStack = vi.fn((id: string) => id === 'target.yml' || id === 'target'); vi.mocked(apiFetch).mockImplementation((url: string) => { const u = String(url); if (u === '/stacks/target.yml') return Promise.resolve(new Response('services: {}', { status: 200 })); if (u.includes('/envs')) return Promise.resolve(new Response(JSON.stringify({ envFiles: [] }), { status: 200 })); if (u.includes('/containers')) return Promise.resolve(new Response('[]', { status: 200 })); if (u.includes('/backup')) return Promise.resolve(new Response(JSON.stringify({ exists: false }), { status: 200 })); return Promise.resolve(new Response('', { status: 404 })); }); const { result, editorState } = setup({ editorState: { content: 'same', originalContent: 'same' }, stackList: { selectedFile: null }, canEditStack, }); await result.current.loadFile('target.yml', { startInComposeEdit: true }); expect(canEditStack).toHaveBeenCalledWith('target.yml'); expect(editorState.setEditingCompose).toHaveBeenCalledWith(true); expect(editorState.setIsEditing).toHaveBeenCalledWith(true); }); it('does not auto-edit when canEditStack rejects the loaded target', async () => { vi.mocked(apiFetch).mockImplementation((url: string) => { const u = String(url); if (u === '/stacks/target.yml') return Promise.resolve(new Response('services: {}', { status: 200 })); if (u.includes('/envs')) return Promise.resolve(new Response(JSON.stringify({ envFiles: [] }), { status: 200 })); if (u.includes('/containers')) return Promise.resolve(new Response('[]', { status: 200 })); if (u.includes('/backup')) return Promise.resolve(new Response(JSON.stringify({ exists: false }), { status: 200 })); return Promise.resolve(new Response('', { status: 404 })); }); const { result, editorState } = setup({ editorState: { content: 'same', originalContent: 'same' }, stackList: { selectedFile: null }, canEditStack: () => false, }); await result.current.loadFile('target.yml', { startInComposeEdit: true }); // loadFile always clears editingCompose at start; never re-opens. expect(editorState.setEditingCompose).toHaveBeenCalledWith(false); expect(editorState.setEditingCompose).not.toHaveBeenCalledWith(true); }); it('forwards pendingLoadOptions through discardAndLoadPending for a dirty stack', async () => { vi.mocked(apiFetch).mockImplementation((url: string) => { const u = String(url); if (u === '/stacks/other.yml') return Promise.resolve(new Response('services: {}', { status: 200 })); if (u.includes('/envs')) return Promise.resolve(new Response(JSON.stringify({ envFiles: [] }), { status: 200 })); if (u.includes('/containers')) return Promise.resolve(new Response('[]', { status: 200 })); if (u.includes('/backup')) return Promise.resolve(new Response(JSON.stringify({ exists: false }), { status: 200 })); return Promise.resolve(new Response('', { status: 404 })); }); // Buffers stay dirty in the mock (setContent does not update content), so // discardAndLoadPending must resume with skipUnsavedCheck or loadFile // would re-defer. const { result, editorState, overlayState } = setup({ editorState: { content: 'dirty', originalContent: 'clean', envContent: '', originalEnvContent: '' }, overlay: { pendingUnsavedLoad: 'other.yml', pendingLoadOptions: { startInComposeEdit: true }, }, stackList: { selectedFile: 'web.yml' }, }); result.current.discardAndLoadPending(); await vi.waitFor(() => { expect(editorState.setEditingCompose).toHaveBeenCalledWith(true); }); // Must not re-stash the pending load. expect(overlayState.setPendingUnsavedLoad).toHaveBeenCalledWith(null); expect(overlayState.setPendingUnsavedLoad).not.toHaveBeenCalledWith('other.yml'); }); it('node-switch pending load sets active node and never calls loadFile with the token', async () => { const setActiveNode = vi.fn(); const targetNode = { id: 9, type: 'remote' } as Parameters[0]['activeNode']; const { result } = setup({ overlay: { pendingUnsavedLoad: '__node-switch-pending__', pendingUnsavedNode: targetNode as never, pendingLoadOptions: { startInComposeEdit: true }, }, setActiveNode, }); result.current.discardAndLoadPending(); expect(setActiveNode).toHaveBeenCalledWith(targetNode); expect(apiFetch).not.toHaveBeenCalled(); }); it('loadFileOnNode defers with options when dirty', async () => { const node = { id: 2, type: 'remote' } as Parameters[0]['activeNode']; const { result, overlayState } = setup({ editorState: { content: 'dirty', originalContent: 'clean' }, stackList: { selectedFile: 'web.yml' }, }); await result.current.loadFileOnNode(node!, 'other.yml', { startInComposeEdit: true }); expect(overlayState.setPendingUnsavedNode).toHaveBeenCalledWith(node); expect(overlayState.setPendingUnsavedLoad).toHaveBeenCalledWith('other.yml'); expect(overlayState.setPendingLoadOptions).toHaveBeenCalledWith({ startInComposeEdit: true }); }); }); describe('useStackActions update readiness routing', () => { beforeEach(() => { vi.mocked(apiFetch).mockReset(); }); function routeUpdateOk() { vi.mocked(apiFetch).mockImplementation((url: string) => { const u = String(url); if (u.includes('/update')) return Promise.resolve(new Response('', { status: 200 })); return Promise.resolve(new Response('[]', { status: 200 })); }); } it('opens the readiness dialog instead of posting when the node has update-guard', async () => { routeUpdateOk(); const { result, overlayState } = setup({ hasUpdateGuard: true }); await act(async () => { await result.current.updateStack(); }); expect(overlayState.setUpdateReadiness).toHaveBeenCalledWith( expect.objectContaining({ stackName: 'web', stackFile: 'web.yml' }), ); expect(apiFetch).not.toHaveBeenCalled(); }); it('routes a sidebar/context-menu update through the readiness dialog too', async () => { routeUpdateOk(); const { result, overlayState } = setup({ hasUpdateGuard: true }); await act(async () => { await result.current.executeStackActionByFile('web.yml', 'update', 'update'); }); expect(overlayState.setUpdateReadiness).toHaveBeenCalledWith( expect.objectContaining({ stackName: 'web', stackFile: 'web.yml' }), ); expect(apiFetch).not.toHaveBeenCalled(); }); it('runs the shared update executor when the dialog proceeds', async () => { routeUpdateOk(); const { result, overlayState, stackListState } = setup({ hasUpdateGuard: true }); await act(async () => { await result.current.updateStack(); }); const pending = vi.mocked(overlayState.setUpdateReadiness).mock.calls[0][0] as { stackName: string; stackFile: string; proceed: () => void }; expect(pending).not.toBeNull(); await act(async () => { pending.proceed(); }); expect(overlayState.setUpdateReadiness).toHaveBeenLastCalledWith(null); const urls = vi.mocked(apiFetch).mock.calls.map(c => String(c[0])); expect(urls).toContain('/stacks/web/update'); expect(stackListState.recordActionSuccess).toHaveBeenCalledWith('web.yml'); }); it('does nothing while the stack is busy, with or without the dialog', async () => { routeUpdateOk(); const { result, overlayState } = setup({ hasUpdateGuard: true, stackList: { isStackBusy: vi.fn().mockReturnValue(true) as never }, }); await act(async () => { await result.current.updateStack(); }); expect(overlayState.setUpdateReadiness).not.toHaveBeenCalled(); expect(apiFetch).not.toHaveBeenCalled(); }); it('updates directly without the capability, from both entry points', async () => { routeUpdateOk(); const { result, overlayState } = setup(); await act(async () => { await result.current.updateStack(); }); await act(async () => { await result.current.executeStackActionByFile('web.yml', 'update', 'update'); }); expect(overlayState.setUpdateReadiness).not.toHaveBeenCalled(); const updatePosts = vi.mocked(apiFetch).mock.calls.filter(c => String(c[0]) === '/stacks/web/update'); expect(updatePosts).toHaveLength(2); }); }); describe('useStackActions recovery records', () => { beforeEach(() => { vi.mocked(apiFetch).mockReset(); }); // Route every apiFetch by URL so the failure paths (which also refetch // /containers) get sensible responses. function routeApi(updateStatus: number, body = '{"error":"boom"}') { vi.mocked(apiFetch).mockImplementation((url: string) => { const u = String(url); if (u.includes('/update') || u.includes('/deploy') || u.includes('/restart')) { return Promise.resolve(new Response(body, { status: updateStatus })); } if (u.includes('/containers')) return Promise.resolve(new Response('[]', { status: 200 })); if (u.includes('/backup')) return Promise.resolve(new Response('{"exists":false,"timestamp":null}', { status: 200 })); return Promise.resolve(new Response('[]', { status: 200 })); }); } it('records a failure and refetches containers when an update fails', async () => { routeApi(500); const { result, stackListState, editorState } = setup(); await act(async () => { await result.current.updateStack(); }); expect(stackListState.recordActionFailure).toHaveBeenCalledWith( 'web.yml', expect.objectContaining({ action: 'update', errorMessage: 'boom', rolledBack: false }), ); expect(editorState.setContainers).toHaveBeenCalled(); expect(stackListState.recordActionSuccess).not.toHaveBeenCalled(); }); it('clears the record on a successful update', async () => { routeApi(200, ''); const { result, stackListState } = setup(); await act(async () => { await result.current.updateStack(); }); expect(stackListState.recordActionSuccess).toHaveBeenCalledWith('web.yml'); expect(stackListState.recordActionFailure).not.toHaveBeenCalled(); }); it('does not record a failure for a stack-op-in-progress 409', async () => { const inProgress = JSON.stringify({ code: 'stack_op_in_progress', inProgress: { action: 'update', startedAt: 1, user: 'someone' }, }); vi.mocked(apiFetch).mockResolvedValue(new Response(inProgress, { status: 409 })); const { result, stackListState } = setup(); await act(async () => { await result.current.updateStack(); }); expect(stackListState.recordActionFailure).not.toHaveBeenCalled(); }); it('stores the deploy-feedback last line only for the matching stack', async () => { routeApi(500); const getLastDeployOutputLine = (stackName: string) => stackName === 'web' ? 'pulling app ...' : undefined; const { result, stackListState } = setup({ getLastDeployOutputLine }); await act(async () => { await result.current.updateStack(); }); expect(stackListState.recordActionFailure).toHaveBeenCalledWith( 'web.yml', expect.objectContaining({ lastOutputLine: 'pulling app ...' }), ); }); it('does not record a recovery panel for a failed stop (not recoverable)', async () => { routeApi(500); const { result, stackListState } = setup(); await act(async () => { await result.current.stopStack(); }); expect(stackListState.recordActionFailure).not.toHaveBeenCalled(); }); it('records a deploy failure and carries the rolledBack flag', async () => { vi.mocked(apiFetch).mockImplementation((url: string) => { const u = String(url); if (u.includes('/deploy')) { return Promise.resolve(new Response('{"error":"crash","rolledBack":true}', { status: 500 })); } if (u.includes('/containers')) return Promise.resolve(new Response('[]', { status: 200 })); return Promise.resolve(new Response('[]', { status: 200 })); }); const { result, stackListState } = setup(); await act(async () => { await result.current.deployStack({ preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent); }); expect(stackListState.recordActionFailure).toHaveBeenCalledWith( 'web.yml', expect.objectContaining({ action: 'deploy', rolledBack: true, errorMessage: 'crash' }), ); }); it('carries the server failure classification into the recovery record', async () => { const body = JSON.stringify({ error: 'port is already allocated', rolledBack: false, failure: { reason: 'port_conflict', label: 'Host port conflict', suggestion: 'Free the port, then retry.' }, }); routeApi(500, body); const { result, stackListState } = setup(); await act(async () => { await result.current.updateStack(); }); expect(stackListState.recordActionFailure).toHaveBeenCalledWith( 'web.yml', expect.objectContaining({ failure: { reason: 'port_conflict', label: 'Host port conflict', suggestion: 'Free the port, then retry.' }, }), ); }); it('ignores a malformed failure field in the response body', async () => { routeApi(500, JSON.stringify({ error: 'boom', failure: { reason: 42 } })); const { result, stackListState } = setup(); await act(async () => { await result.current.updateStack(); }); expect(stackListState.recordActionFailure).toHaveBeenCalledWith( 'web.yml', expect.objectContaining({ failure: undefined }), ); }); it('synthesizes a node_unreachable classification for a gateway 502 with no body', async () => { routeApi(502, 'Bad Gateway'); const { result, stackListState } = setup(); await act(async () => { await result.current.updateStack(); }); expect(stackListState.recordActionFailure).toHaveBeenCalledWith( 'web.yml', expect.objectContaining({ failure: expect.objectContaining({ reason: 'node_unreachable' }), }), ); }); it('does not mislabel an unrelated JSON 503 as node_unreachable', async () => { routeApi(503, JSON.stringify({ error: 'maintenance window' })); const { result, stackListState } = setup(); await act(async () => { await result.current.updateStack(); }); expect(stackListState.recordActionFailure).toHaveBeenCalledWith( 'web.yml', expect.objectContaining({ failure: undefined }), ); }); it('synthesizes node_unreachable for a docker_unavailable 503 without a classified body', async () => { routeApi(503, JSON.stringify({ error: 'daemon gone', code: 'docker_unavailable' })); const { result, stackListState } = setup(); await act(async () => { await result.current.updateStack(); }); expect(stackListState.recordActionFailure).toHaveBeenCalledWith( 'web.yml', expect.objectContaining({ failure: expect.objectContaining({ reason: 'node_unreachable' }), }), ); }); it('records a rollback failure', async () => { vi.mocked(apiFetch).mockImplementation((url: string) => { const u = String(url); if (u.includes('/rollback')) return Promise.resolve(new Response('{"error":"no backup"}', { status: 500 })); if (u.includes('/containers')) return Promise.resolve(new Response('[]', { status: 200 })); return Promise.resolve(new Response('[]', { status: 200 })); }); const { result, stackListState } = setup(); await act(async () => { await result.current.rollbackStack(); }); expect(stackListState.recordActionFailure).toHaveBeenCalledWith( 'web.yml', expect.objectContaining({ action: 'rollback', rolledBack: false, errorMessage: 'no backup' }), ); }); it('does not record a failure when only the post-rollback refetch fails', async () => { let rolledBack = false; vi.mocked(apiFetch).mockImplementation((url: string) => { const u = String(url); if (u.includes('/rollback')) { rolledBack = true; return Promise.resolve(new Response(null, { status: 200 })); } // After a successful rollback, the cosmetic content refetch throws. if (rolledBack && u.endsWith('/stacks/web.yml')) return Promise.reject(new Error('network blip')); return Promise.resolve(new Response('[]', { status: 200 })); }); const { result, stackListState } = setup(); await act(async () => { await result.current.rollbackStack(); }); expect(stackListState.recordActionSuccess).toHaveBeenCalledWith('web.yml'); expect(stackListState.recordActionFailure).not.toHaveBeenCalled(); }); it('refreshes containers after a successful rollback (rollback redeploys)', async () => { vi.mocked(apiFetch).mockImplementation((url: string) => { const u = String(url); if (u.includes('/rollback')) return Promise.resolve(new Response(null, { status: 200 })); if (u.includes('/containers')) { return Promise.resolve(new Response('[{"Id":"c1","Names":["/web"],"State":"running"}]', { status: 200 })); } return Promise.resolve(new Response('', { status: 200 })); }); const { result, stackListState, editorState } = setup(); await act(async () => { await result.current.rollbackStack(); }); expect(stackListState.recordActionSuccess).toHaveBeenCalledWith('web.yml'); expect(editorState.setContainers).toHaveBeenCalledWith( expect.arrayContaining([expect.objectContaining({ Id: 'c1' })]), ); expect(stackListState.recordActionFailure).not.toHaveBeenCalled(); }); it('does not record a rollback failure when the post-rollback container refresh fails', async () => { vi.mocked(apiFetch).mockImplementation((url: string) => { const u = String(url); if (u.includes('/rollback')) return Promise.resolve(new Response(null, { status: 200 })); if (u.includes('/containers')) return Promise.reject(new Error('network blip')); return Promise.resolve(new Response('', { status: 200 })); }); const { result, stackListState } = setup(); await act(async () => { await result.current.rollbackStack(); }); expect(stackListState.recordActionSuccess).toHaveBeenCalledWith('web.yml'); expect(stackListState.recordActionFailure).not.toHaveBeenCalled(); }); }); describe('useStackActions.getStackMenuVisibility', () => { it('gives a partial stack the running-stack lifecycle actions', () => { const { result } = setup({ stackList: { stackStatuses: { 'web.yml': 'partial' } as never } }); expect(result.current.getStackMenuVisibility('web.yml')).toEqual({ showDeploy: false, showStop: true, showRestart: true, showUpdate: true, showTakeDown: true, }); }); it('offers Take down for a running non-self stack', () => { const { result } = setup({ stackList: { stackStatuses: { 'web.yml': 'running' } as never } }); expect(result.current.getStackMenuVisibility('web.yml')).toEqual({ showDeploy: false, showStop: true, showRestart: true, showUpdate: true, showTakeDown: true, }); }); it('shows deploy (not stop/restart/update) for an exited stack', () => { const { result } = setup({ stackList: { stackStatuses: { 'web.yml': 'exited' } as never } }); expect(result.current.getStackMenuVisibility('web.yml')).toEqual({ showDeploy: true, showStop: false, showRestart: false, showUpdate: false, showTakeDown: true, }); }); it('hides guarded lifecycle actions for the self stack but keeps restart', () => { const { result } = setup({ stackList: { stackStatuses: { 'sencho.yml': 'running' } as never, stackSelfFlags: { 'sencho.yml': true }, }, }); expect(result.current.getStackMenuVisibility('sencho.yml')).toEqual({ showDeploy: false, showStop: false, showRestart: true, showUpdate: false, showTakeDown: false, }); }); it('opens the self-stack modal instead of calling update on a protected stack', async () => { const { result, overlayState, stackListState } = setup({ stackList: { selectedFile: 'sencho.yml', stackSelfFlags: { 'sencho.yml': true }, }, }); await act(async () => { await result.current.updateStack(); }); expect(overlayState.openSelfStackProtected).toHaveBeenCalled(); expect(stackListState.setStackAction).not.toHaveBeenCalled(); }); it('opens the self-stack modal instead of calling rollback on a protected stack', async () => { vi.mocked(apiFetch).mockReset(); const { result, overlayState, stackListState } = setup({ stackList: { selectedFile: 'sencho.yml', stackSelfFlags: { 'sencho.yml': true }, }, }); await act(async () => { await result.current.rollbackStack(); }); expect(overlayState.openSelfStackProtected).toHaveBeenCalled(); expect(stackListState.setStackAction).not.toHaveBeenCalled(); expect(apiFetch).not.toHaveBeenCalled(); }); it('opens the self-stack modal for rollback 409 fallback', async () => { vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify({ code: 'self_stack_protected' }), { status: 409 })); const { result, overlayState, stackListState } = setup(); await act(async () => { await result.current.rollbackStack(); }); expect(overlayState.openSelfStackProtected).toHaveBeenCalled(); expect(stackListState.recordActionFailure).not.toHaveBeenCalled(); }); it('opens the self-stack modal instead of calling service stop on a protected stack', async () => { vi.mocked(apiFetch).mockReset(); const { result, overlayState } = setup({ stackList: { selectedFile: 'sencho.yml', stackSelfFlags: { 'sencho.yml': true }, }, }); await act(async () => { await result.current.serviceAction('stop', 'web'); }); expect(overlayState.openSelfStackProtected).toHaveBeenCalled(); expect(apiFetch).not.toHaveBeenCalled(); }); it('opens the self-stack modal for service stop 409 fallback', async () => { vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify({ code: 'self_stack_protected' }), { status: 409 })); const { result, overlayState } = setup(); await act(async () => { await result.current.serviceAction('stop', 'web'); }); expect(overlayState.openSelfStackProtected).toHaveBeenCalled(); }); }); describe('useStackActions.openStackApp', () => { beforeEach(() => { vi.mocked(apiFetch).mockReset(); }); function openAndCaptureHref(over: Parameters[0]): { href: string | undefined; clickCount: number; } { let href: string | undefined; const click = vi .spyOn(HTMLAnchorElement.prototype, 'click') .mockImplementation(function (this: HTMLAnchorElement) { href = this.href; }); try { const { result } = setup(over); result.current.openStackApp('web.yml'); return { href, clickCount: click.mock.calls.length }; } finally { click.mockRestore(); } } it('opens the published port on the browser host for a local node', () => { const { href } = openAndCaptureHref({ stackList: { stackPorts: { 'web.yml': 8989 } } }); expect(href).toBe('http://localhost:8989/'); }); it('opens on the remote node host derived from its api_url', () => { const { href } = openAndCaptureHref({ stackList: { stackPorts: { 'web.yml': 8989 } }, activeNode: { id: 2, type: 'remote', api_url: 'http://10.0.0.5:1852' } as Parameters[0]['activeNode'], }); expect(href).toBe('http://10.0.0.5:8989/'); }); it('does nothing for a remote node with no api_url (pilot) and does not throw', () => { const { href, clickCount } = openAndCaptureHref({ stackList: { stackPorts: { 'web.yml': 8989 } }, activeNode: { id: 3, type: 'remote', api_url: '' } as Parameters[0]['activeNode'], }); expect(clickCount).toBe(0); expect(href).toBeUndefined(); }); it('appends a known service path via the published port', () => { const { href } = openAndCaptureHref({ stackList: { stackPorts: { 'web.yml': 32400 } } }); expect(href).toBe('http://localhost:32400/web'); }); it('does nothing when the stack has no published port', () => { const { clickCount } = openAndCaptureHref({}); expect(clickCount).toBe(0); }); });