From 8edf8216fe34e49b8752f84505ff0ed51b2186f7 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Thu, 28 May 2026 10:57:18 -0400 Subject: [PATCH 1/2] fix(dev): use TUI picker for harness deploy when launching dev from interactive menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When selecting "dev" from the interactive `agentcore` menu with preview enabled, harness deploy was rendered inline via runCliDeploy() instead of using the full-screen TUI (DevScreen) that `agentcore dev` uses directly. Now launchBrowserDev() uses launchTuiDevScreenWithPicker() — the same alt-screen TUI picker used by the direct `agentcore dev` command — when preview is enabled and harnesses are present, so deploy progress renders identically regardless of entry path. Closes #1376 Constraint: Must be gated behind isPreviewEnabled() to match GA behavior Rejected: Adding ENTER_ALT_SCREEN around runCliDeploy | would still show spinner-based output instead of the structured DevScreen TUI Confidence: high Scope-risk: narrow --- .../dev/__tests__/browser-mode.test.ts | 186 ++++++++++++++++++ src/cli/commands/dev/browser-mode.ts | 22 ++- 2 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 src/cli/commands/dev/__tests__/browser-mode.test.ts diff --git a/src/cli/commands/dev/__tests__/browser-mode.test.ts b/src/cli/commands/dev/__tests__/browser-mode.test.ts new file mode 100644 index 000000000..8f58deeec --- /dev/null +++ b/src/cli/commands/dev/__tests__/browser-mode.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockLoadProjectConfig = vi.fn(); +const mockGetWorkingDirectory = vi.fn().mockReturnValue('/fake/project'); +const mockFindConfigRoot = vi.fn().mockReturnValue('/fake/project'); +const mockStartOtelCollector = vi.fn().mockResolvedValue({ collector: {}, otelEnvVars: {} }); +const mockRunWebUI = vi.fn().mockResolvedValue(undefined); +const mockLoadDevEnv = vi.fn().mockResolvedValue({ envVars: {} }); +const mockGetDevSupportedAgents = vi.fn().mockReturnValue([]); +const mockIsPreviewEnabled = vi.fn(); +const mockRunCliDeploy = vi.fn().mockResolvedValue(undefined); + +vi.mock('../../../../lib', () => ({ + findConfigRoot: (...args: unknown[]) => mockFindConfigRoot(...args), + getWorkingDirectory: () => mockGetWorkingDirectory(), + ConfigIO: class MockConfigIO { + configExists = vi.fn().mockReturnValue(false); + }, +})); + +vi.mock('../../../feature-flags', () => ({ + isPreviewEnabled: () => mockIsPreviewEnabled(), +})); + +vi.mock('../../../operations/dev', () => ({ + loadProjectConfig: (...args: unknown[]) => mockLoadProjectConfig(...args), + getDevConfig: vi.fn(), + getDevSupportedAgents: (...args: unknown[]) => mockGetDevSupportedAgents(...args), + loadDevEnv: (...args: unknown[]) => mockLoadDevEnv(...args), +})); + +vi.mock('../../../operations/dev/otel', () => ({ + startOtelCollector: (...args: unknown[]) => mockStartOtelCollector(...args), +})); + +vi.mock('../../../operations/dev/web-ui', () => ({ + runWebUI: (...args: unknown[]) => mockRunWebUI(...args), +})); + +vi.mock('../../../operations/memory', () => ({ + listMemoryRecords: vi.fn(), + retrieveMemoryRecords: vi.fn(), +})); + +vi.mock('../../../operations/resolve-agent', () => ({ + loadDeployedProjectConfig: vi.fn(), + resolveAgentOrHarness: vi.fn(), +})); + +vi.mock('../../../operations/traces', () => ({ + fetchTraceRecords: vi.fn(), + listTraces: vi.fn(), +})); + +vi.mock('../../deploy/progress', () => ({ + runCliDeploy: (...args: unknown[]) => mockRunCliDeploy(...args), +})); + +vi.mock('../../../tui/context', () => ({ + LayoutProvider: ({ children }: { children: unknown }) => children, +})); + +const mockRender = vi.fn(); +vi.mock('ink', () => ({ + render: (...args: unknown[]) => mockRender(...args), +})); + +vi.mock('react', () => ({ + default: { createElement: vi.fn((_type, _props, ..._children) => ({ type: _type, props: _props })) }, + createElement: vi.fn((_type, _props, ..._children) => ({ type: _type, props: _props })), +})); + +const mockStdoutWrite = vi.fn(); + +describe('launchBrowserDev', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockStdoutWrite.mockReturnValue(true); + vi.spyOn(process.stdout, 'write').mockImplementation(mockStdoutWrite); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('when preview is enabled and project has harnesses', () => { + it('uses TUI picker with alt screen for deploy instead of inline runCliDeploy', async () => { + const { launchBrowserDev } = await import('../browser-mode'); + + mockIsPreviewEnabled.mockReturnValue(true); + mockLoadProjectConfig.mockResolvedValue({ + runtimes: [{ name: 'my-agent', build: 'CodeZip', protocol: 'HTTP' }], + harnesses: [{ name: 'my-harness' }], + }); + mockGetDevSupportedAgents.mockReturnValue([{ name: 'my-agent', build: 'CodeZip', protocol: 'HTTP' }]); + + mockRender.mockImplementation((element: { props: Record }) => { + const onLaunchBrowser = element.props?.onLaunchBrowser as + | ((selection?: { agentName?: string; harnessName?: string }) => void) + | undefined; + if (onLaunchBrowser) { + onLaunchBrowser({ agentName: 'my-agent', harnessName: 'my-harness' }); + } + return { unmount: vi.fn(), waitUntilExit: () => Promise.resolve() }; + }); + + await launchBrowserDev(); + + // Verify alt screen was entered (TUI picker path) + expect(mockStdoutWrite).toHaveBeenCalledWith('\x1B[?1049h\x1B[H'); + // Verify render was called (DevScreen TUI was used) + expect(mockRender).toHaveBeenCalled(); + // Verify runCliDeploy was NOT called (deploy handled by TUI picker) + expect(mockRunCliDeploy).not.toHaveBeenCalled(); + }); + + it('does not proceed to browser mode when user backs out of TUI picker', async () => { + const { launchBrowserDev } = await import('../browser-mode'); + + mockIsPreviewEnabled.mockReturnValue(true); + mockLoadProjectConfig.mockResolvedValue({ + runtimes: [{ name: 'my-agent', build: 'CodeZip', protocol: 'HTTP' }], + harnesses: [{ name: 'my-harness' }], + }); + mockGetDevSupportedAgents.mockReturnValue([{ name: 'my-agent', build: 'CodeZip', protocol: 'HTTP' }]); + + mockRender.mockImplementation((element: { props: Record }) => { + const onBack = element.props?.onBack as (() => void) | undefined; + if (onBack) onBack(); + return { unmount: vi.fn(), waitUntilExit: () => Promise.resolve() }; + }); + + await launchBrowserDev(); + + expect(mockStdoutWrite).toHaveBeenCalledWith('\x1B[?1049h\x1B[H'); + expect(mockStdoutWrite).toHaveBeenCalledWith('\x1B[?1049l'); + expect(mockRunWebUI).not.toHaveBeenCalled(); + expect(mockRunCliDeploy).not.toHaveBeenCalled(); + }); + }); + + describe('when preview is disabled', () => { + it('skips harnesses and launches browser mode directly without TUI picker', async () => { + const { launchBrowserDev } = await import('../browser-mode'); + + mockIsPreviewEnabled.mockReturnValue(false); + mockLoadProjectConfig.mockResolvedValue({ + runtimes: [{ name: 'my-agent', build: 'CodeZip', protocol: 'HTTP' }], + harnesses: [{ name: 'my-harness' }], + }); + mockGetDevSupportedAgents.mockReturnValue([{ name: 'my-agent', build: 'CodeZip', protocol: 'HTTP' }]); + + await launchBrowserDev(); + + // Should NOT enter alt screen or use TUI picker + expect(mockStdoutWrite).not.toHaveBeenCalledWith('\x1B[?1049h\x1B[H'); + expect(mockRender).not.toHaveBeenCalled(); + // Should go straight to browser mode + expect(mockRunWebUI).toHaveBeenCalled(); + }); + }); + + describe('error cases', () => { + it('exits when no project is found', async () => { + const { launchBrowserDev } = await import('../browser-mode'); + mockIsPreviewEnabled.mockReturnValue(true); + mockLoadProjectConfig.mockResolvedValue(null); + + await expect(launchBrowserDev()).rejects.toThrow('process.exit called'); + }); + + it('exits when project has no runtimes or harnesses', async () => { + const { launchBrowserDev } = await import('../browser-mode'); + mockIsPreviewEnabled.mockReturnValue(true); + mockLoadProjectConfig.mockResolvedValue({ + runtimes: [], + harnesses: [], + }); + + await expect(launchBrowserDev()).rejects.toThrow('process.exit called'); + }); + }); +}); diff --git a/src/cli/commands/dev/browser-mode.ts b/src/cli/commands/dev/browser-mode.ts index 0a6b0885d..861073ee9 100644 --- a/src/cli/commands/dev/browser-mode.ts +++ b/src/cli/commands/dev/browser-mode.ts @@ -14,7 +14,6 @@ import { listMemoryRecords, retrieveMemoryRecords } from '../../operations/memor import { loadDeployedProjectConfig, resolveAgentOrHarness } from '../../operations/resolve-agent'; import { fetchTraceRecords, listTraces } from '../../operations/traces'; import { LayoutProvider } from '../../tui/context'; -import { runCliDeploy } from '../deploy/progress'; import { render } from 'ink'; import path from 'node:path'; import React from 'react'; @@ -133,7 +132,26 @@ export async function launchBrowserDev(): Promise { } if (hasHarnesses) { - await runCliDeploy(); + const pickerResult = await launchTuiDevScreenWithPicker(workingDir); + + if (pickerResult == null) { + return; + } + + const configRoot = findConfigRoot(workingDir); + const persistTracesDir = path.join(configRoot ?? workingDir, '.cli', 'traces'); + const { collector, otelEnvVars } = await startOtelCollector(persistTracesDir); + + await runBrowserMode({ + workingDir, + project, + port: 8080, + agentName: pickerResult.agentName, + harnessName: pickerResult.harnessName, + otelEnvVars, + collector, + }); + return; } const configRoot = findConfigRoot(workingDir); From b48a9571c485bb16cccaa28b9040ebbdd935e8ab Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Fri, 29 May 2026 09:52:17 -0400 Subject: [PATCH 2/2] fix(dev): simplify implementation and trim tests Remove duplicated OTEL/runBrowserMode code by using pickerResult with optional chaining in the shared path. Trim test file to only the two meaningful cases: picker used for deploy, and user back-out handled. --- .../dev/__tests__/browser-mode.test.ts | 122 ++++++------------ src/cli/commands/dev/browser-mode.ts | 21 +-- 2 files changed, 42 insertions(+), 101 deletions(-) diff --git a/src/cli/commands/dev/__tests__/browser-mode.test.ts b/src/cli/commands/dev/__tests__/browser-mode.test.ts index 8f58deeec..a3ec162c8 100644 --- a/src/cli/commands/dev/__tests__/browser-mode.test.ts +++ b/src/cli/commands/dev/__tests__/browser-mode.test.ts @@ -86,101 +86,53 @@ describe('launchBrowserDev', () => { vi.restoreAllMocks(); }); - describe('when preview is enabled and project has harnesses', () => { - it('uses TUI picker with alt screen for deploy instead of inline runCliDeploy', async () => { - const { launchBrowserDev } = await import('../browser-mode'); - - mockIsPreviewEnabled.mockReturnValue(true); - mockLoadProjectConfig.mockResolvedValue({ - runtimes: [{ name: 'my-agent', build: 'CodeZip', protocol: 'HTTP' }], - harnesses: [{ name: 'my-harness' }], - }); - mockGetDevSupportedAgents.mockReturnValue([{ name: 'my-agent', build: 'CodeZip', protocol: 'HTTP' }]); - - mockRender.mockImplementation((element: { props: Record }) => { - const onLaunchBrowser = element.props?.onLaunchBrowser as - | ((selection?: { agentName?: string; harnessName?: string }) => void) - | undefined; - if (onLaunchBrowser) { - onLaunchBrowser({ agentName: 'my-agent', harnessName: 'my-harness' }); - } - return { unmount: vi.fn(), waitUntilExit: () => Promise.resolve() }; - }); - - await launchBrowserDev(); - - // Verify alt screen was entered (TUI picker path) - expect(mockStdoutWrite).toHaveBeenCalledWith('\x1B[?1049h\x1B[H'); - // Verify render was called (DevScreen TUI was used) - expect(mockRender).toHaveBeenCalled(); - // Verify runCliDeploy was NOT called (deploy handled by TUI picker) - expect(mockRunCliDeploy).not.toHaveBeenCalled(); - }); - - it('does not proceed to browser mode when user backs out of TUI picker', async () => { - const { launchBrowserDev } = await import('../browser-mode'); - - mockIsPreviewEnabled.mockReturnValue(true); - mockLoadProjectConfig.mockResolvedValue({ - runtimes: [{ name: 'my-agent', build: 'CodeZip', protocol: 'HTTP' }], - harnesses: [{ name: 'my-harness' }], - }); - mockGetDevSupportedAgents.mockReturnValue([{ name: 'my-agent', build: 'CodeZip', protocol: 'HTTP' }]); + it('uses TUI picker for deploy when preview enabled and harnesses present', async () => { + const { launchBrowserDev } = await import('../browser-mode'); - mockRender.mockImplementation((element: { props: Record }) => { - const onBack = element.props?.onBack as (() => void) | undefined; - if (onBack) onBack(); - return { unmount: vi.fn(), waitUntilExit: () => Promise.resolve() }; - }); - - await launchBrowserDev(); - - expect(mockStdoutWrite).toHaveBeenCalledWith('\x1B[?1049h\x1B[H'); - expect(mockStdoutWrite).toHaveBeenCalledWith('\x1B[?1049l'); - expect(mockRunWebUI).not.toHaveBeenCalled(); - expect(mockRunCliDeploy).not.toHaveBeenCalled(); + mockIsPreviewEnabled.mockReturnValue(true); + mockLoadProjectConfig.mockResolvedValue({ + runtimes: [{ name: 'my-agent', build: 'CodeZip', protocol: 'HTTP' }], + harnesses: [{ name: 'my-harness' }], + }); + mockGetDevSupportedAgents.mockReturnValue([{ name: 'my-agent', build: 'CodeZip', protocol: 'HTTP' }]); + + mockRender.mockImplementation((element: { props: Record }) => { + const onLaunchBrowser = element.props?.onLaunchBrowser as + | ((selection?: { agentName?: string; harnessName?: string }) => void) + | undefined; + if (onLaunchBrowser) { + onLaunchBrowser({ agentName: 'my-agent', harnessName: 'my-harness' }); + } + return { unmount: vi.fn(), waitUntilExit: () => Promise.resolve() }; }); - }); - describe('when preview is disabled', () => { - it('skips harnesses and launches browser mode directly without TUI picker', async () => { - const { launchBrowserDev } = await import('../browser-mode'); + await launchBrowserDev(); - mockIsPreviewEnabled.mockReturnValue(false); - mockLoadProjectConfig.mockResolvedValue({ - runtimes: [{ name: 'my-agent', build: 'CodeZip', protocol: 'HTTP' }], - harnesses: [{ name: 'my-harness' }], - }); - mockGetDevSupportedAgents.mockReturnValue([{ name: 'my-agent', build: 'CodeZip', protocol: 'HTTP' }]); + expect(mockStdoutWrite).toHaveBeenCalledWith('\x1B[?1049h\x1B[H'); + expect(mockRender).toHaveBeenCalled(); + expect(mockRunCliDeploy).not.toHaveBeenCalled(); + }); - await launchBrowserDev(); + it('does not launch browser mode when user backs out of TUI picker', async () => { + const { launchBrowserDev } = await import('../browser-mode'); - // Should NOT enter alt screen or use TUI picker - expect(mockStdoutWrite).not.toHaveBeenCalledWith('\x1B[?1049h\x1B[H'); - expect(mockRender).not.toHaveBeenCalled(); - // Should go straight to browser mode - expect(mockRunWebUI).toHaveBeenCalled(); + mockIsPreviewEnabled.mockReturnValue(true); + mockLoadProjectConfig.mockResolvedValue({ + runtimes: [{ name: 'my-agent', build: 'CodeZip', protocol: 'HTTP' }], + harnesses: [{ name: 'my-harness' }], }); - }); + mockGetDevSupportedAgents.mockReturnValue([{ name: 'my-agent', build: 'CodeZip', protocol: 'HTTP' }]); - describe('error cases', () => { - it('exits when no project is found', async () => { - const { launchBrowserDev } = await import('../browser-mode'); - mockIsPreviewEnabled.mockReturnValue(true); - mockLoadProjectConfig.mockResolvedValue(null); - - await expect(launchBrowserDev()).rejects.toThrow('process.exit called'); + mockRender.mockImplementation((element: { props: Record }) => { + const onBack = element.props?.onBack as (() => void) | undefined; + if (onBack) onBack(); + return { unmount: vi.fn(), waitUntilExit: () => Promise.resolve() }; }); - it('exits when project has no runtimes or harnesses', async () => { - const { launchBrowserDev } = await import('../browser-mode'); - mockIsPreviewEnabled.mockReturnValue(true); - mockLoadProjectConfig.mockResolvedValue({ - runtimes: [], - harnesses: [], - }); + await launchBrowserDev(); - await expect(launchBrowserDev()).rejects.toThrow('process.exit called'); - }); + expect(mockStdoutWrite).toHaveBeenCalledWith('\x1B[?1049h\x1B[H'); + expect(mockStdoutWrite).toHaveBeenCalledWith('\x1B[?1049l'); + expect(mockRunWebUI).not.toHaveBeenCalled(); }); }); diff --git a/src/cli/commands/dev/browser-mode.ts b/src/cli/commands/dev/browser-mode.ts index 861073ee9..68084f20d 100644 --- a/src/cli/commands/dev/browser-mode.ts +++ b/src/cli/commands/dev/browser-mode.ts @@ -131,27 +131,14 @@ export async function launchBrowserDev(): Promise { process.exit(1); } + let pickerResult: { agentName?: string; harnessName?: string } | undefined; + if (hasHarnesses) { - const pickerResult = await launchTuiDevScreenWithPicker(workingDir); + pickerResult = await launchTuiDevScreenWithPicker(workingDir); if (pickerResult == null) { return; } - - const configRoot = findConfigRoot(workingDir); - const persistTracesDir = path.join(configRoot ?? workingDir, '.cli', 'traces'); - const { collector, otelEnvVars } = await startOtelCollector(persistTracesDir); - - await runBrowserMode({ - workingDir, - project, - port: 8080, - agentName: pickerResult.agentName, - harnessName: pickerResult.harnessName, - otelEnvVars, - collector, - }); - return; } const configRoot = findConfigRoot(workingDir); @@ -162,6 +149,8 @@ export async function launchBrowserDev(): Promise { workingDir, project, port: 8080, + agentName: pickerResult?.agentName, + harnessName: pickerResult?.harnessName, otelEnvVars, collector, });