diff --git a/src/cli/commands/dev/__tests__/launch-browser-dev.test.ts b/src/cli/commands/dev/__tests__/launch-browser-dev.test.ts new file mode 100644 index 000000000..3c00bcbea --- /dev/null +++ b/src/cli/commands/dev/__tests__/launch-browser-dev.test.ts @@ -0,0 +1,90 @@ +// Regression test for #1376: launching dev from the interactive `agentcore` +// menu must render the harness deploy inside the alt-screen DevScreen TUI (the +// same path `agentcore dev` uses) instead of plain inline console.log lines. +import type { AgentCoreProjectSpec } from '../../../../schema'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const harnessOnlyProject = { + runtimes: [], + harnesses: [{ name: 'my-harness' }], +} as unknown as AgentCoreProjectSpec; + +const { mockRunCliDeploy, mockRunWebUI, mockIsDeploySkippable, mockLoadProjectConfig, mockRender } = vi.hoisted(() => ({ + mockRunCliDeploy: vi.fn().mockResolvedValue(undefined), + mockRunWebUI: vi.fn().mockResolvedValue(undefined), + mockIsDeploySkippable: vi.fn(), + mockLoadProjectConfig: vi.fn(), + mockRender: vi.fn(), +})); + +vi.mock('../../deploy/progress', () => ({ runCliDeploy: mockRunCliDeploy })); +vi.mock('../../../operations/deploy/change-detection', () => ({ isDeploySkippable: mockIsDeploySkippable })); +vi.mock('../../../tui/screens/dev/DevScreen', () => ({ DevScreen: 'DevScreen' })); +vi.mock('../../../tui/context', () => ({ LayoutProvider: 'LayoutProvider' })); +vi.mock('ink', () => ({ render: (...args: unknown[]) => mockRender(...args) })); + +vi.mock('../../../lib', () => ({ + getWorkingDirectory: () => '/proj', + findConfigRoot: () => '/proj', + ConfigIO: class { + configExists() { + return false; + } + }, +})); +vi.mock('../../../operations/dev', () => ({ + loadProjectConfig: mockLoadProjectConfig, + getDevSupportedAgents: () => [], + getDevConfig: () => undefined, + loadDevEnv: vi.fn().mockResolvedValue({ envVars: {} }), +})); +vi.mock('../../../operations/dev/otel', () => ({ + startOtelCollector: vi.fn().mockResolvedValue({ collector: undefined, otelEnvVars: {} }), +})); +vi.mock('../../../operations/dev/web-ui', () => ({ runWebUI: mockRunWebUI })); + +import { launchBrowserDev } from '../browser-mode'; + +/** Drive the rendered DevScreen by invoking the prop callback the picker passes. */ +function driveDevScreen(invoke: (props: Record) => void): void { + mockRender.mockImplementation((element: { props: { children: { props: Record } } }) => { + // Defer so the picker's `unmount` binding is initialized before the callback runs. + const exited = Promise.resolve().then(() => invoke(element.props.children.props)); + return { unmount: vi.fn(), waitUntilExit: () => exited }; + }); +} + +describe('launchBrowserDev (#1376 entry-path consistency)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLoadProjectConfig.mockResolvedValue(harnessOnlyProject); + }); + + afterEach(() => { + mockRender.mockReset(); + }); + + it('shows the alt-screen DevScreen TUI for a harness deploy instead of inline runCliDeploy', async () => { + mockIsDeploySkippable.mockResolvedValue(false); + driveDevScreen(props => (props.onLaunchBrowser as (s: unknown) => void)({ harnessName: 'my-harness' })); + + await launchBrowserDev(); + + expect(mockRunCliDeploy).not.toHaveBeenCalled(); + expect(mockRender).toHaveBeenCalledOnce(); + expect(mockRunWebUI).toHaveBeenCalledOnce(); + const webUiArgs = mockRunWebUI.mock.calls[0]?.[0] as { serverOptions: { selectedHarness?: string } }; + expect(webUiArgs.serverOptions.selectedHarness).toBe('my-harness'); + }); + + it('returns early without launching the web UI when the picker is cancelled', async () => { + mockIsDeploySkippable.mockResolvedValue(false); + // Picker resolves with no selection (waitUntilExit resolves, onLaunchBrowser never fires). + driveDevScreen(() => undefined); + + await launchBrowserDev(); + + expect(mockRunCliDeploy).not.toHaveBeenCalled(); + expect(mockRunWebUI).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/commands/dev/browser-mode.ts b/src/cli/commands/dev/browser-mode.ts index b40600e21..09cfec7a6 100644 --- a/src/cli/commands/dev/browser-mode.ts +++ b/src/cli/commands/dev/browser-mode.ts @@ -15,7 +15,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,15 +132,31 @@ export async function launchBrowserDev(): Promise { process.exit(1); } - // Only auto-deploy for harness-only projects, and skip if no CDK changes - if (hasHarnesses && !hasRuntimes && !(await isDeploySkippable())) { - await runCliDeploy(); - } - const configRoot = findConfigRoot(workingDir); const persistTracesDir = path.join(configRoot ?? workingDir, '.cli', 'traces'); const { collector, otelEnvVars } = await startOtelCollector(persistTracesDir); + // Only auto-deploy for harness-only projects, and skip if no CDK changes. + // Show deploy progress inside the alt-screen DevScreen TUI (the same path + // `agentcore dev` uses) instead of plain inline console output, so both + // entry points render identically. + if (hasHarnesses && !hasRuntimes && !(await isDeploySkippable())) { + const pickerResult = await launchTuiDevScreenWithPicker(workingDir); + if (pickerResult == null) { + return; + } + await runBrowserMode({ + workingDir, + project, + port: 8080, + agentName: pickerResult.agentName, + harnessName: pickerResult.harnessName, + otelEnvVars, + collector, + }); + return; + } + await runBrowserMode({ workingDir, project,