feat(cli): add browser automation commands

This commit is contained in:
Tam Nhu Tran
2026-04-16 18:49:24 -04:00
parent b6aef885ad
commit 4e30c9b080
42 changed files with 3028 additions and 32 deletions
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from 'vitest';
import { render, screen, userEvent } from '@tests/setup/test-utils';
import { CodexMcpServersCard } from '@/components/compatible-cli/codex-mcp-servers-card';
describe('CodexMcpServersCard', () => {
it('blocks creating the reserved ccs_browser entry from the generic MCP editor', async () => {
render(<CodexMcpServersCard entries={[]} onSave={vi.fn()} onDelete={vi.fn()} />);
const nameInput = screen.getByPlaceholderText('playwright');
await userEvent.type(nameInput, 'ccs_browser');
expect(
screen.getAllByText(
(_, element) =>
element?.textContent?.includes(
'ccs_browser is reserved for the CCS-managed browser tooling path.'
) ?? false
)[0]
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Save MCP server' })).toBeDisabled();
});
});
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { readCodexMcpServers } from '@/lib/codex-config';
describe('readCodexMcpServers', () => {
it('marks the CCS browser MCP entry as managed by Browser settings', () => {
const entries = readCodexMcpServers({
mcp_servers: {
ccs_browser: {
command: 'npx',
args: ['-y', '@playwright/mcp@0.0.70'],
enabled: true,
},
playwright: {
command: 'npx',
args: ['-y', '@playwright/mcp@latest'],
enabled: true,
},
},
});
expect(entries).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'ccs_browser',
isCcsManaged: true,
managementSurface: 'browser-settings',
}),
expect.objectContaining({
name: 'playwright',
isCcsManaged: false,
managementSurface: null,
}),
])
);
});
});
@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, userEvent } from '@tests/setup/test-utils';
const mocks = vi.hoisted(() => ({
useBrowserConfig: vi.fn(),
useRawConfig: vi.fn(),
fetchConfig: vi.fn(),
fetchStatus: vi.fn(),
saveConfig: vi.fn(),
fetchRawConfig: vi.fn(),
}));
vi.mock('@/pages/settings/hooks', async () => {
const actual =
await vi.importActual<typeof import('@/pages/settings/hooks')>('@/pages/settings/hooks');
return {
...actual,
useBrowserConfig: mocks.useBrowserConfig,
useRawConfig: mocks.useRawConfig,
};
});
import BrowserSection from '@/pages/settings/sections/browser';
describe('BrowserSection', () => {
beforeEach(() => {
mocks.fetchConfig.mockReset();
mocks.fetchStatus.mockReset();
mocks.saveConfig.mockReset();
mocks.fetchRawConfig.mockReset();
mocks.useRawConfig.mockReturnValue({
rawConfig: 'browser:\n claude:\n enabled: true\n',
loading: false,
copied: false,
fetchRawConfig: mocks.fetchRawConfig,
copyToClipboard: vi.fn(),
});
mocks.useBrowserConfig.mockReturnValue({
config: {
claude: {
enabled: true,
userDataDir: '/tmp/browser-profile',
devtoolsPort: 9222,
},
codex: {
enabled: true,
},
},
status: {
claude: {
enabled: true,
source: 'config',
overrideActive: false,
state: 'ready',
title: 'Claude Browser Attach is ready.',
detail: 'CCS can reach the configured Chrome DevTools endpoint.',
nextStep: 'Launch Claude.',
effectiveUserDataDir: '/tmp/browser-profile',
recommendedUserDataDir: '/tmp/browser-profile',
devtoolsPort: 9222,
managedMcpServerName: 'ccs-browser',
managedMcpServerPath: '/tmp/ccs-browser-server.cjs',
launchCommands: {
darwin:
'open -na "Google Chrome" --args --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"',
linux:
'google-chrome --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"',
win32: 'chrome.exe --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"',
},
},
codex: {
enabled: true,
state: 'enabled',
title: 'Codex Browser Tools are enabled.',
detail: 'CCS can inject managed Playwright MCP overrides.',
nextStep: 'Use a Codex-target launch.',
serverName: 'ccs_browser',
supportsConfigOverrides: true,
binaryPath: '/usr/local/bin/codex',
version: 'codex-cli 0.120.0',
},
},
loading: false,
statusLoading: false,
saving: false,
error: null,
success: false,
fetchConfig: mocks.fetchConfig,
fetchStatus: mocks.fetchStatus,
saveConfig: mocks.saveConfig,
});
});
it('uses the current draft for launch guidance and disables testing until the draft is saved', async () => {
render(<BrowserSection />, { withSettingsProvider: true });
const pathInput = screen.getByLabelText('Chrome user-data directory');
await userEvent.clear(pathInput);
await userEvent.type(pathInput, '/tmp/new-browser-profile');
const launchCommand = screen.getByText(/new-browser-profile/);
expect(launchCommand).toBeInTheDocument();
const testConnectionButton = screen.getByRole('button', { name: 'Test connection' });
expect(testConnectionButton).toBeDisabled();
});
});
@@ -31,6 +31,10 @@ vi.mock('@/pages/settings/sections/websearch', () => ({
default: () => <div>WebSearch Section</div>,
}));
vi.mock('@/pages/settings/sections/browser', () => ({
default: () => <div>Browser Section</div>,
}));
vi.mock('@/pages/settings/sections/channels', () => ({
default: () => <div>Channels Section</div>,
}));
@@ -70,6 +74,7 @@ describe('SettingsPage raw config panel', () => {
});
it('keeps the current config editor visible while raw config refreshes', async () => {
window.history.pushState({}, '', '/settings');
mocks.useRawConfig.mockReturnValue({
rawConfig: 'websearch:\n enabled: true\n',
loading: true,
@@ -84,4 +89,19 @@ describe('SettingsPage raw config panel', () => {
expect(screen.getByLabelText('config editor')).toHaveValue('websearch:\n enabled: true\n');
expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
});
it('renders the Browser section when the settings tab query is browser', async () => {
window.history.pushState({}, '', '/settings?tab=browser');
mocks.useRawConfig.mockReturnValue({
rawConfig: 'browser:\n claude:\n enabled: false\n',
loading: false,
copied: false,
fetchRawConfig: mocks.fetchRawConfig,
copyToClipboard: mocks.copyToClipboard,
});
render(<SettingsPage />);
expect(await screen.findAllByText('Browser Section')).toHaveLength(2);
});
});