Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions src/cli/commands/dev/__tests__/launch-browser-dev.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => void): void {
mockRender.mockImplementation((element: { props: { children: { props: Record<string, unknown> } } }) => {
// 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();
});
});
27 changes: 21 additions & 6 deletions src/cli/commands/dev/browser-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -133,15 +132,31 @@ export async function launchBrowserDev(): Promise<void> {
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,
Expand Down
Loading