mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 10:23:07 +00:00
Bug fixes and stability improvements (#815)
* fix(copilot): correct tool.execution_complete event handling The CopilotProvider was using incorrect event type and data structure for tool execution completion events from the @github/copilot-sdk, causing tool call outputs to be empty. Changes: - Update event type from 'tool.execution_end' to 'tool.execution_complete' - Fix data structure to use nested result.content instead of flat result - Fix error structure to use error.message instead of flat error - Add success field to match SDK event structure - Add tests for empty and missing result handling This aligns with the official @github/copilot-sdk v0.1.16 types defined in session-events.d.ts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(copilot): add edge case test for error with code field Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(copilot): improve error handling and code quality Code review improvements: - Extract magic string '[ERROR]' to TOOL_ERROR_PREFIX constant - Add null-safe error handling with direct error variable assignment - Include error codes in error messages for better debugging - Add JSDoc documentation for tool.execution_complete handler - Update tests to verify error codes are displayed - Add missing tool_use_id assertion in error test These changes improve: - Code maintainability (no magic strings) - Debugging experience (error codes now visible) - Type safety (explicit null checks) - Test coverage (verify error code formatting) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Changes from fix/bug-fixes-1-0 * test(copilot): add edge case test for error with code field Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Changes from fix/bug-fixes-1-0 * fix: Handle detached HEAD state in worktree discovery and recovery * fix: Remove unused isDevServerStarting prop and md: breakpoint classes * fix: Add missing dependency and sanitize persisted cache data * feat: Ensure NODE_ENV is set to test in vitest configs * feat: Configure Playwright to run only E2E tests * fix: Improve PR tracking and dev server lifecycle management * feat: Add settings-based defaults for planning mode, model config, and custom providers. Fixes #816 * feat: Add worktree and branch selector to graph view * fix: Add timeout and error handling for worktree HEAD ref resolution * fix: use absolute icon path and place icon outside asar on Linux The hicolor icon theme index only lists sizes up to 512x512, so an icon installed only at 1024x1024 is invisible to GNOME/KDE's theme resolver, causing both the app launcher and taskbar to show a generic icon. Additionally, BrowserWindow.icon cannot be read by the window manager when the file is inside app.asar. - extraResources: copy logo_larger.png to resources/ (outside asar) so it lands at /opt/Automaker/resources/logo_larger.png on install - linux.desktop.Icon: set to the absolute resources path, bypassing the hicolor theme lookup and its size constraints entirely - icon-manager.ts: on Linux production use process.resourcesPath so BrowserWindow receives a real filesystem path the WM can read directly Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: use linux.desktop.entry for custom desktop Icon field electron-builder v26 rejects arbitrary keys in linux.desktop — the correct schema wraps custom .desktop overrides inside desktop.entry. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: set desktop name on Linux so taskbar uses the correct app icon Without app.setDesktopName(), the window manager cannot associate the running Electron process with automaker.desktop. GNOME/KDE fall back to _NET_WM_ICON which defaults to Electron's own bundled icon. Calling app.setDesktopName('automaker.desktop') before any window is created sets the _GTK_APPLICATION_ID hint and XDG app_id so the WM picks up the desktop entry's Icon for the taskbar. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix: memory and context views mobile friendly (#818) * Changes from fix/memory-and-context-mobile-friendly * fix: Improve file extension detection and add path traversal protection * refactor: Extract file extension utilities and add path traversal guards Code review improvements: - Extract isMarkdownFilename and isImageFilename to shared image-utils.ts - Remove duplicated code from context-view.tsx and memory-view.tsx - Add path traversal guard for context fixture utilities (matching memory) - Add 7 new tests for context fixture path traversal protection - Total 61 tests pass Addresses code review feedback from PR #813 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: Add e2e tests for profiles crud and board background persistence * Update apps/ui/playwright.config.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix: Add robust test navigation handling and file filtering * fix: Format NODE_OPTIONS configuration on single line * test: Update profiles and board background persistence tests * test: Replace iPhone 13 Pro with Pixel 5 for mobile test consistency * Update apps/ui/src/components/views/context-view.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * chore: Remove test project directory * feat: Filter context files by type and improve mobile menu visibility --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix: Improve test reliability and localhost handling * chore: Use explicit TEST_USE_EXTERNAL_BACKEND env var for server cleanup * feat: Add E2E/CI mock mode for provider factory and auth verification * feat: Add remoteBranch parameter to pull and rebase operations * chore: Enhance E2E testing setup with worker isolation and auth state management - Updated .gitignore to include worker-specific test fixtures. - Modified e2e-tests.yml to implement test sharding for improved CI performance. - Refactored global setup to authenticate once and save session state for reuse across tests. - Introduced worker-isolated fixture paths to prevent conflicts during parallel test execution. - Improved test navigation and loading handling for better reliability. - Updated various test files to utilize new auth state management and fixture paths. * fix: Update Playwright configuration and improve test reliability - Increased the number of workers in Playwright configuration for better parallelism in CI environments. - Enhanced the board background persistence test to ensure dropdown stability by waiting for the list to populate before interaction, improving test reliability. * chore: Simplify E2E test configuration and enhance mock implementations - Updated e2e-tests.yml to run tests in a single shard for streamlined CI execution. - Enhanced unit tests for worktree list handling by introducing a mock for execGitCommand, improving test reliability and coverage. - Refactored setup functions to better manage command mocks for git operations in tests. - Improved error handling in mkdirSafe function to account for undefined stats in certain environments. * refactor: Improve test configurations and enhance error handling - Updated Playwright configuration to clear VITE_SERVER_URL, ensuring the frontend uses the Vite proxy and preventing cookie domain mismatches. - Enhanced MergeRebaseDialog logic to normalize selectedBranch for better handling of various ref formats. - Improved global setup with a more robust backend health check, throwing an error if the backend is not healthy after retries. - Refactored project creation tests to handle file existence checks more reliably. - Added error handling for missing E2E source fixtures to guide setup process. - Enhanced memory navigation to handle sandbox dialog visibility more effectively. * refactor: Enhance Git command execution and improve test configurations - Updated Git command execution to merge environment paths correctly, ensuring proper command execution context. - Refactored the Git initialization process to handle errors more gracefully and ensure user configuration is set before creating the initial commit. - Improved test configurations by updating Playwright test identifiers for better clarity and consistency across different project states. - Enhanced cleanup functions in tests to handle directory removal more robustly, preventing errors during test execution. * fix: Resolve React hooks errors from duplicate instances in dependency tree * style: Format alias configuration for improved readability --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: DhanushSantosh <dhanushsantoshs05@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Tests for AgentInfoPanel merge_conflict status handling
|
||||
* Verifies that merge_conflict status is treated like backlog for:
|
||||
* - shouldFetchData (no polling for merge_conflict features)
|
||||
* - Rendering path (shows model/preset info like backlog)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AgentInfoPanel } from '../../../src/components/views/board-view/components/kanban-card/agent-info-panel';
|
||||
import { useAppStore } from '@automaker/ui/store/app-store';
|
||||
import { useFeature, useAgentOutput } from '@automaker/ui/hooks/queries';
|
||||
import { getElectronAPI } from '@automaker/ui/lib/electron';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@automaker/ui/store/app-store');
|
||||
vi.mock('@automaker/ui/hooks/queries');
|
||||
vi.mock('@automaker/ui/lib/electron');
|
||||
|
||||
const mockUseAppStore = vi.mocked(useAppStore);
|
||||
const mockUseFeature = vi.mocked(useFeature);
|
||||
const mockUseAgentOutput = vi.mocked(useAgentOutput);
|
||||
const mockGetElectronAPI = vi.mocked(getElectronAPI);
|
||||
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('AgentInfoPanel - merge_conflict status', () => {
|
||||
const createMockFeature = (overrides = {}) => ({
|
||||
id: 'feature-merge-test',
|
||||
title: 'Test Feature',
|
||||
description: 'Test feature',
|
||||
status: 'merge_conflict',
|
||||
model: 'claude-sonnet-4-5',
|
||||
providerId: undefined,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockUseAppStore.mockImplementation((selector) => {
|
||||
const state = {
|
||||
claudeCompatibleProviders: [],
|
||||
};
|
||||
return selector(state);
|
||||
});
|
||||
|
||||
mockUseFeature.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useFeature>);
|
||||
|
||||
mockUseAgentOutput.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
} as ReturnType<typeof useAgentOutput>);
|
||||
|
||||
mockGetElectronAPI.mockReturnValue(null);
|
||||
});
|
||||
|
||||
it('should render model info for merge_conflict features (like backlog)', () => {
|
||||
const feature = createMockFeature({ status: 'merge_conflict' });
|
||||
|
||||
render(<AgentInfoPanel feature={feature} projectPath="/test/project" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// merge_conflict features should show model name like backlog
|
||||
expect(screen.getByText('Sonnet 4.5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render model info for backlog features (baseline comparison)', () => {
|
||||
const feature = createMockFeature({ status: 'backlog' });
|
||||
|
||||
render(<AgentInfoPanel feature={feature} projectPath="/test/project" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Sonnet 4.5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show provider-aware model name for merge_conflict features', () => {
|
||||
mockUseAppStore.mockImplementation((selector) => {
|
||||
const state = {
|
||||
claudeCompatibleProviders: [
|
||||
{
|
||||
id: 'moonshot-ai',
|
||||
name: 'Moonshot AI',
|
||||
models: [{ id: 'claude-sonnet-4-5', displayName: 'Moonshot v1.8' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
return selector(state);
|
||||
});
|
||||
|
||||
const feature = createMockFeature({
|
||||
status: 'merge_conflict',
|
||||
model: 'claude-sonnet-4-5',
|
||||
providerId: 'moonshot-ai',
|
||||
});
|
||||
|
||||
render(<AgentInfoPanel feature={feature} projectPath="/test/project" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Moonshot v1.8')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not pass isActivelyRunning polling for merge_conflict features', () => {
|
||||
const feature = createMockFeature({ status: 'merge_conflict' });
|
||||
|
||||
// Render without isActivelyRunning (merge_conflict features should not be polled)
|
||||
render(<AgentInfoPanel feature={feature} projectPath="/test/project" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// useFeature and useAgentOutput should have been called but with shouldFetchData=false behavior
|
||||
// The key indicator is that the component renders the backlog-like model info view
|
||||
expect(screen.getByText('Sonnet 4.5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show thinking level for merge_conflict Claude features', () => {
|
||||
const feature = createMockFeature({
|
||||
status: 'merge_conflict',
|
||||
model: 'claude-sonnet-4-5',
|
||||
thinkingLevel: 'high',
|
||||
});
|
||||
|
||||
render(<AgentInfoPanel feature={feature} projectPath="/test/project" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Sonnet 4.5')).toBeInTheDocument();
|
||||
// ThinkingLevel indicator should be visible
|
||||
expect(screen.getByText('High')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
295
apps/ui/tests/unit/components/agent-info-panel.test.tsx
Normal file
295
apps/ui/tests/unit/components/agent-info-panel.test.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Unit tests for AgentInfoPanel component
|
||||
* Tests provider-aware model name display functionality
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { AgentInfoPanel } from '../../../src/components/views/board-view/components/kanban-card/agent-info-panel';
|
||||
import { useAppStore } from '@automaker/ui/store/app-store';
|
||||
import { useFeature, useAgentOutput } from '@automaker/ui/hooks/queries';
|
||||
import { getElectronAPI } from '@automaker/ui/lib/electron';
|
||||
import type { ClaudeCompatibleProvider } from '@automaker/types';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@automaker/ui/store/app-store');
|
||||
vi.mock('@automaker/ui/hooks/queries');
|
||||
vi.mock('@automaker/ui/lib/electron');
|
||||
|
||||
const mockUseAppStore = useAppStore as ReturnType<typeof vi.fn>;
|
||||
const mockUseFeature = useFeature as ReturnType<typeof vi.fn>;
|
||||
const mockUseAgentOutput = useAgentOutput as ReturnType<typeof vi.fn>;
|
||||
const mockGetElectronAPI = getElectronAPI as ReturnType<typeof vi.fn>;
|
||||
|
||||
// Helper to create wrapper with QueryClient
|
||||
function createWrapper() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return function Wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('AgentInfoPanel', () => {
|
||||
const mockProviders: ClaudeCompatibleProvider[] = [
|
||||
{
|
||||
id: 'moonshot-ai',
|
||||
name: 'Moonshot AI',
|
||||
models: [
|
||||
{ id: 'claude-sonnet-4-5', displayName: 'Moonshot v1.8' },
|
||||
{ id: 'claude-opus-4-6', displayName: 'Moonshot v1.8 Pro' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'zhipu',
|
||||
name: 'Zhipu AI',
|
||||
models: [{ id: 'claude-sonnet-4-5', displayName: 'GLM 4.7' }],
|
||||
},
|
||||
];
|
||||
|
||||
const createMockFeature = (overrides = {}) => ({
|
||||
id: 'feature-test-123',
|
||||
description: 'Test feature',
|
||||
status: 'backlog',
|
||||
model: 'claude-sonnet-4-5',
|
||||
providerId: undefined,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Default mock implementations
|
||||
mockUseAppStore.mockImplementation((selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
claudeCompatibleProviders: [],
|
||||
};
|
||||
return selector(state);
|
||||
});
|
||||
|
||||
mockUseFeature.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockUseAgentOutput.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
mockGetElectronAPI.mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe('Provider-aware model name display', () => {
|
||||
it('should display provider displayName when providerId matches Moonshot AI', () => {
|
||||
mockUseAppStore.mockImplementation(
|
||||
(selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
claudeCompatibleProviders: mockProviders,
|
||||
};
|
||||
return selector(state);
|
||||
}
|
||||
);
|
||||
|
||||
const feature = createMockFeature({
|
||||
status: 'backlog',
|
||||
model: 'claude-sonnet-4-5',
|
||||
providerId: 'moonshot-ai',
|
||||
});
|
||||
|
||||
render(<AgentInfoPanel feature={feature} projectPath="/test/project" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Moonshot v1.8')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display provider displayName when providerId matches Zhipu/GLM', () => {
|
||||
mockUseAppStore.mockImplementation(
|
||||
(selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
claudeCompatibleProviders: mockProviders,
|
||||
};
|
||||
return selector(state);
|
||||
}
|
||||
);
|
||||
|
||||
const feature = createMockFeature({
|
||||
status: 'backlog',
|
||||
model: 'claude-sonnet-4-5',
|
||||
providerId: 'zhipu',
|
||||
});
|
||||
|
||||
render(<AgentInfoPanel feature={feature} projectPath="/test/project" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('GLM 4.7')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should fallback to default model name when providerId is not found', () => {
|
||||
mockUseAppStore.mockImplementation(
|
||||
(selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
claudeCompatibleProviders: mockProviders,
|
||||
};
|
||||
return selector(state);
|
||||
}
|
||||
);
|
||||
|
||||
const feature = createMockFeature({
|
||||
status: 'backlog',
|
||||
model: 'claude-sonnet-4-5',
|
||||
providerId: 'unknown-provider',
|
||||
});
|
||||
|
||||
render(<AgentInfoPanel feature={feature} projectPath="/test/project" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Falls back to default formatting
|
||||
expect(screen.getByText('Sonnet 4.5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should fallback to default model name when providers list is empty', () => {
|
||||
mockUseAppStore.mockImplementation(
|
||||
(selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
claudeCompatibleProviders: [],
|
||||
};
|
||||
return selector(state);
|
||||
}
|
||||
);
|
||||
|
||||
const feature = createMockFeature({
|
||||
status: 'backlog',
|
||||
model: 'claude-sonnet-4-5',
|
||||
providerId: 'moonshot-ai',
|
||||
});
|
||||
|
||||
render(<AgentInfoPanel feature={feature} projectPath="/test/project" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Falls back to default formatting
|
||||
expect(screen.getByText('Sonnet 4.5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use default model name when providerId is undefined', () => {
|
||||
mockUseAppStore.mockImplementation(
|
||||
(selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
claudeCompatibleProviders: mockProviders,
|
||||
};
|
||||
return selector(state);
|
||||
}
|
||||
);
|
||||
|
||||
const feature = createMockFeature({
|
||||
status: 'backlog',
|
||||
model: 'claude-sonnet-4-5',
|
||||
providerId: undefined,
|
||||
});
|
||||
|
||||
render(<AgentInfoPanel feature={feature} projectPath="/test/project" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// Uses default formatting since no providerId
|
||||
expect(screen.getByText('Sonnet 4.5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display correct model name for Opus models with provider', () => {
|
||||
mockUseAppStore.mockImplementation(
|
||||
(selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
claudeCompatibleProviders: mockProviders,
|
||||
};
|
||||
return selector(state);
|
||||
}
|
||||
);
|
||||
|
||||
const feature = createMockFeature({
|
||||
status: 'backlog',
|
||||
model: 'claude-opus-4-6',
|
||||
providerId: 'moonshot-ai',
|
||||
});
|
||||
|
||||
render(<AgentInfoPanel feature={feature} projectPath="/test/project" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Moonshot v1.8 Pro')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should memoize model format options to prevent unnecessary re-renders', () => {
|
||||
mockUseAppStore.mockImplementation(
|
||||
(selector: (state: Record<string, unknown>) => unknown) => {
|
||||
const state = {
|
||||
claudeCompatibleProviders: mockProviders,
|
||||
};
|
||||
return selector(state);
|
||||
}
|
||||
);
|
||||
|
||||
const feature = createMockFeature({
|
||||
status: 'backlog',
|
||||
model: 'claude-sonnet-4-5',
|
||||
providerId: 'moonshot-ai',
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<AgentInfoPanel feature={feature} projectPath="/test/project" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Rerender with the same feature (simulating parent re-render)
|
||||
rerender(<AgentInfoPanel feature={feature} projectPath="/test/project" />);
|
||||
|
||||
// The component should use memoized options and still display correctly
|
||||
expect(screen.getByText('Moonshot v1.8')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Model name display for different statuses', () => {
|
||||
it('should show model info for backlog features', () => {
|
||||
const feature = createMockFeature({
|
||||
status: 'backlog',
|
||||
model: 'claude-sonnet-4-5',
|
||||
});
|
||||
|
||||
render(<AgentInfoPanel feature={feature} projectPath="/test/project" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Sonnet 4.5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show model info for in_progress features with agentInfo', () => {
|
||||
mockUseAgentOutput.mockReturnValue({
|
||||
data: '🔧 Tool: Read\nInput: {"file": "test.ts"}',
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
const feature = createMockFeature({
|
||||
status: 'in_progress',
|
||||
model: 'claude-sonnet-4-5',
|
||||
});
|
||||
|
||||
render(
|
||||
<AgentInfoPanel feature={feature} projectPath="/test/project" isActivelyRunning={true} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Sonnet 4.5')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Tests for AgentOutputModal constants
|
||||
* Verifies MODAL_CONSTANTS values used throughout the modal component
|
||||
* to ensure centralized configuration is correct and type-safe.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MODAL_CONSTANTS } from '../../../src/components/views/board-view/dialogs/agent-output-modal.constants';
|
||||
|
||||
describe('MODAL_CONSTANTS', () => {
|
||||
describe('AUTOSCROLL_THRESHOLD', () => {
|
||||
it('should be a positive number for scroll detection', () => {
|
||||
expect(MODAL_CONSTANTS.AUTOSCROLL_THRESHOLD).toBe(50);
|
||||
expect(typeof MODAL_CONSTANTS.AUTOSCROLL_THRESHOLD).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MODAL_CLOSE_DELAY_MS', () => {
|
||||
it('should provide reasonable delay for modal auto-close', () => {
|
||||
expect(MODAL_CONSTANTS.MODAL_CLOSE_DELAY_MS).toBe(1500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VIEW_MODES', () => {
|
||||
it('should define all four view modes', () => {
|
||||
expect(MODAL_CONSTANTS.VIEW_MODES).toEqual({
|
||||
SUMMARY: 'summary',
|
||||
PARSED: 'parsed',
|
||||
RAW: 'raw',
|
||||
CHANGES: 'changes',
|
||||
});
|
||||
});
|
||||
|
||||
it('should have string values for each mode', () => {
|
||||
expect(typeof MODAL_CONSTANTS.VIEW_MODES.SUMMARY).toBe('string');
|
||||
expect(typeof MODAL_CONSTANTS.VIEW_MODES.PARSED).toBe('string');
|
||||
expect(typeof MODAL_CONSTANTS.VIEW_MODES.RAW).toBe('string');
|
||||
expect(typeof MODAL_CONSTANTS.VIEW_MODES.CHANGES).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HEIGHT_CONSTRAINTS', () => {
|
||||
it('should define mobile, small, and tablet height constraints', () => {
|
||||
expect(MODAL_CONSTANTS.HEIGHT_CONSTRAINTS.MOBILE_MAX_DVH).toBe('85dvh');
|
||||
expect(MODAL_CONSTANTS.HEIGHT_CONSTRAINTS.SMALL_MAX_VH).toBe('80vh');
|
||||
expect(MODAL_CONSTANTS.HEIGHT_CONSTRAINTS.TABLET_MAX_VH).toBe('85vh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WIDTH_CONSTRAINTS', () => {
|
||||
it('should define responsive width constraints', () => {
|
||||
expect(MODAL_CONSTANTS.WIDTH_CONSTRAINTS.MOBILE_MAX_CALC).toBe('calc(100% - 2rem)');
|
||||
expect(MODAL_CONSTANTS.WIDTH_CONSTRAINTS.SMALL_MAX_VW).toBe('60vw');
|
||||
expect(MODAL_CONSTANTS.WIDTH_CONSTRAINTS.TABLET_MAX_VW).toBe('90vw');
|
||||
expect(MODAL_CONSTANTS.WIDTH_CONSTRAINTS.TABLET_MAX_WIDTH).toBe('1200px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('COMPONENT_HEIGHTS', () => {
|
||||
it('should define complete Tailwind class fragments for template interpolation', () => {
|
||||
expect(MODAL_CONSTANTS.COMPONENT_HEIGHTS.SMALL_MIN).toBe('sm:min-h-[200px]');
|
||||
expect(MODAL_CONSTANTS.COMPONENT_HEIGHTS.SMALL_MAX).toBe('sm:max-h-[60vh]');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
/**
|
||||
* Integration tests for AgentOutputModal component
|
||||
*
|
||||
* These tests verify the actual functionality and user interactions of the modal,
|
||||
* including view mode switching, content display, and event handling.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import { AgentOutputModal } from '../../../src/components/views/board-view/dialogs/agent-output-modal';
|
||||
import { useAppStore } from '@automaker/ui/store/app-store';
|
||||
import {
|
||||
useAgentOutput,
|
||||
useFeature,
|
||||
useWorktreeDiffs,
|
||||
useGitDiffs,
|
||||
} from '@automaker/ui/hooks/queries';
|
||||
import { getElectronAPI } from '@automaker/ui/lib/electron';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@automaker/ui/hooks/queries');
|
||||
vi.mock('@automaker/ui/lib/electron');
|
||||
vi.mock('@automaker/ui/store/app-store');
|
||||
|
||||
const mockUseAppStore = vi.mocked(useAppStore);
|
||||
const mockUseAgentOutput = vi.mocked(useAgentOutput);
|
||||
const mockUseFeature = vi.mocked(useFeature);
|
||||
const mockGetElectronAPI = vi.mocked(getElectronAPI);
|
||||
const mockUseWorktreeDiffs = vi.mocked(useWorktreeDiffs);
|
||||
const mockUseGitDiffs = vi.mocked(useGitDiffs);
|
||||
|
||||
describe('AgentOutputModal Integration Tests', () => {
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
featureDescription: 'Implement a responsive navigation menu',
|
||||
featureId: 'feature-test-123',
|
||||
featureStatus: 'running',
|
||||
};
|
||||
|
||||
const mockOutput = `
|
||||
# Agent Output
|
||||
|
||||
## Planning Phase
|
||||
- Analyzing requirements
|
||||
- Creating implementation plan
|
||||
|
||||
## Action Phase
|
||||
- Created navigation component
|
||||
- Added responsive styles
|
||||
- Implemented mobile menu toggle
|
||||
|
||||
## Summary
|
||||
Successfully implemented a responsive navigation menu with hamburger menu for mobile view.
|
||||
`;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock useAppStore
|
||||
mockUseAppStore.mockImplementation((selector) => {
|
||||
if (selector === 'state') {
|
||||
return { useWorktrees: false };
|
||||
}
|
||||
return selector({ useWorktrees: false });
|
||||
});
|
||||
|
||||
// Mock useAgentOutput
|
||||
mockUseAgentOutput.mockReturnValue({
|
||||
data: mockOutput,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as ReturnType<typeof useAgentOutput>);
|
||||
|
||||
// Mock useFeature
|
||||
mockUseFeature.mockReturnValue({
|
||||
data: null,
|
||||
refetch: vi.fn(),
|
||||
} as ReturnType<typeof useFeature>);
|
||||
|
||||
// Mock useWorktreeDiffs (needed for GitDiffPanel in changes view)
|
||||
mockUseWorktreeDiffs.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useWorktreeDiffs>);
|
||||
|
||||
// Mock useGitDiffs (also needed for GitDiffPanel)
|
||||
mockUseGitDiffs.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
} as ReturnType<typeof useGitDiffs>);
|
||||
|
||||
// Mock electron API
|
||||
mockGetElectronAPI.mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Modal Opening and Closing', () => {
|
||||
it('should render modal when open is true', () => {
|
||||
render(<AgentOutputModal {...defaultProps} />);
|
||||
expect(screen.getByTestId('agent-output-modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render modal when open is false', () => {
|
||||
render(<AgentOutputModal {...defaultProps} open={false} />);
|
||||
expect(screen.queryByTestId('agent-output-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have onClose callback available', () => {
|
||||
render(<AgentOutputModal {...defaultProps} />);
|
||||
// Verify the onClose function is provided
|
||||
expect(defaultProps.onClose).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('View Mode Switching', () => {
|
||||
beforeEach(() => {
|
||||
// Clean up any existing content
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('should render all view mode buttons', () => {
|
||||
render(<AgentOutputModal {...defaultProps} />);
|
||||
|
||||
// All view mode buttons should be present
|
||||
expect(screen.getByTestId('view-mode-parsed')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('view-mode-changes')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('view-mode-raw')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should switch to logs view when logs button is clicked', async () => {
|
||||
render(<AgentOutputModal {...defaultProps} />);
|
||||
|
||||
const logsButton = screen.getByTestId('view-mode-parsed');
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify the logs button is now active
|
||||
expect(logsButton).toHaveClass('bg-primary/20');
|
||||
});
|
||||
});
|
||||
|
||||
it('should switch to raw view when raw button is clicked', async () => {
|
||||
render(<AgentOutputModal {...defaultProps} />);
|
||||
|
||||
const rawButton = screen.getByTestId('view-mode-raw');
|
||||
fireEvent.click(rawButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify the raw button is now active
|
||||
expect(rawButton).toHaveClass('bg-primary/20');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content Display', () => {
|
||||
it('should display feature description', () => {
|
||||
render(<AgentOutputModal {...defaultProps} />);
|
||||
|
||||
const description = screen.getByTestId('agent-output-description');
|
||||
expect(description).toHaveTextContent('Implement a responsive navigation menu');
|
||||
});
|
||||
|
||||
it('should show loading state when output is loading', () => {
|
||||
mockUseAgentOutput.mockReturnValue({
|
||||
data: '',
|
||||
isLoading: true,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as ReturnType<typeof useAgentOutput>);
|
||||
|
||||
render(<AgentOutputModal {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Loading output...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show no output message when output is empty', () => {
|
||||
mockUseAgentOutput.mockReturnValue({
|
||||
data: '',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as ReturnType<typeof useAgentOutput>);
|
||||
|
||||
render(<AgentOutputModal {...defaultProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByText('No output yet. The agent will stream output here as it works.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display parsed output in LogViewer', () => {
|
||||
render(<AgentOutputModal {...defaultProps} />);
|
||||
|
||||
// The button text is "Logs" (case-sensitive)
|
||||
expect(screen.getByText('Logs')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Spinner Display', () => {
|
||||
it('should not show spinner when status is verified', () => {
|
||||
render(<AgentOutputModal {...defaultProps} featureStatus="verified" />);
|
||||
|
||||
// Spinner should NOT be present when status is verified
|
||||
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show spinner when status is waiting_approval', () => {
|
||||
render(<AgentOutputModal {...defaultProps} featureStatus="waiting_approval" />);
|
||||
|
||||
// Spinner should NOT be present when status is waiting_approval
|
||||
expect(screen.queryByTestId('spinner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show spinner when status is running', () => {
|
||||
render(<AgentOutputModal {...defaultProps} featureStatus="running" />);
|
||||
|
||||
// Spinner should be present and visible when status is running
|
||||
expect(screen.getByTestId('spinner')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Number Key Handling', () => {
|
||||
it('should handle number key presses when modal is open', () => {
|
||||
const mockOnNumberKeyPress = vi.fn();
|
||||
render(<AgentOutputModal {...defaultProps} onNumberKeyPress={mockOnNumberKeyPress} />);
|
||||
|
||||
// Simulate number key press
|
||||
fireEvent.keyDown(window, { key: '1', ctrlKey: false, altKey: false, metaKey: false });
|
||||
|
||||
expect(mockOnNumberKeyPress).toHaveBeenCalledWith('1');
|
||||
});
|
||||
|
||||
it('should not handle number keys with modifiers', () => {
|
||||
const mockOnNumberKeyPress = vi.fn();
|
||||
render(<AgentOutputModal {...defaultProps} onNumberKeyPress={mockOnNumberKeyPress} />);
|
||||
|
||||
// Simulate Ctrl+1 (should be ignored)
|
||||
fireEvent.keyDown(window, { key: '1', ctrlKey: true, altKey: false, metaKey: false });
|
||||
fireEvent.keyDown(window, { key: '2', altKey: true, ctrlKey: false, metaKey: false });
|
||||
fireEvent.keyDown(window, { key: '3', metaKey: true, ctrlKey: false, altKey: false });
|
||||
|
||||
expect(mockOnNumberKeyPress).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not handle number key presses when modal is closed', () => {
|
||||
const mockOnNumberKeyPress = vi.fn();
|
||||
render(
|
||||
<AgentOutputModal {...defaultProps} open={false} onNumberKeyPress={mockOnNumberKeyPress} />
|
||||
);
|
||||
|
||||
fireEvent.keyDown(window, { key: '1', ctrlKey: false, altKey: false, metaKey: false });
|
||||
|
||||
expect(mockOnNumberKeyPress).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto-scrolling', () => {
|
||||
it('should auto-scroll to bottom when output changes', async () => {
|
||||
const { rerender } = render(<AgentOutputModal {...defaultProps} />);
|
||||
|
||||
// Find the scroll container - the div with overflow-y-auto that contains the log output
|
||||
const modal = screen.getByTestId('agent-output-modal');
|
||||
const scrollContainer = modal.querySelector('.overflow-y-auto.font-mono') as HTMLDivElement;
|
||||
|
||||
expect(scrollContainer).toBeInTheDocument();
|
||||
|
||||
// Mock the scrollHeight to simulate content growth
|
||||
Object.defineProperty(scrollContainer, 'scrollHeight', {
|
||||
value: 1000,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Simulate output update by changing the mock return value
|
||||
mockUseAgentOutput.mockReturnValue({
|
||||
data: mockOutput + '\n\n## New Content\nThis is additional content that was streamed.',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as ReturnType<typeof useAgentOutput>);
|
||||
|
||||
// Re-render the component to trigger the auto-scroll effect
|
||||
await act(async () => {
|
||||
rerender(<AgentOutputModal {...defaultProps} />);
|
||||
});
|
||||
|
||||
// The auto-scroll effect sets scrollTop directly to scrollHeight
|
||||
// Verify scrollTop was updated to the scrollHeight value
|
||||
expect(scrollContainer.scrollTop).toBe(1000);
|
||||
});
|
||||
|
||||
it('should update scrollTop when output is appended', async () => {
|
||||
const { rerender } = render(<AgentOutputModal {...defaultProps} />);
|
||||
|
||||
const modal = screen.getByTestId('agent-output-modal');
|
||||
const scrollContainer = modal.querySelector('.overflow-y-auto.font-mono') as HTMLDivElement;
|
||||
|
||||
expect(scrollContainer).toBeInTheDocument();
|
||||
|
||||
// Set initial scrollHeight
|
||||
Object.defineProperty(scrollContainer, 'scrollHeight', {
|
||||
value: 500,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Initial state - scrollTop should be set after first render
|
||||
// (autoScrollRef.current starts as true)
|
||||
|
||||
// Now simulate more content being added
|
||||
Object.defineProperty(scrollContainer, 'scrollHeight', {
|
||||
value: 1500,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
mockUseAgentOutput.mockReturnValue({
|
||||
data: mockOutput + '\n\nMore content added.',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as ReturnType<typeof useAgentOutput>);
|
||||
|
||||
await act(async () => {
|
||||
rerender(<AgentOutputModal {...defaultProps} />);
|
||||
});
|
||||
|
||||
// Verify scrollTop was updated to the new scrollHeight
|
||||
expect(scrollContainer.scrollTop).toBe(1500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backlog Plan Mode', () => {
|
||||
it('should handle backlog plan feature ID', () => {
|
||||
const backlogProps = {
|
||||
...defaultProps,
|
||||
featureId: 'backlog-plan:project-123',
|
||||
};
|
||||
|
||||
render(<AgentOutputModal {...backlogProps} />);
|
||||
|
||||
expect(screen.getByText('Agent Output')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Project Path Resolution', () => {
|
||||
it('should use projectPath prop when provided', () => {
|
||||
const projectPath = '/custom/project/path';
|
||||
render(<AgentOutputModal {...defaultProps} projectPath={projectPath} />);
|
||||
|
||||
expect(screen.getByText('Implement a responsive navigation menu')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should fallback to window.__currentProject when projectPath is not provided', () => {
|
||||
const previousProject = window.__currentProject;
|
||||
try {
|
||||
window.__currentProject = { path: '/fallback/project' };
|
||||
render(<AgentOutputModal {...defaultProps} />);
|
||||
expect(screen.getByText('Implement a responsive navigation menu')).toBeInTheDocument();
|
||||
} finally {
|
||||
window.__currentProject = previousProject;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Branch Name Handling', () => {
|
||||
it('should display changes view when branchName is provided', async () => {
|
||||
render(<AgentOutputModal {...defaultProps} branchName="feature/test-branch" />);
|
||||
|
||||
// Switch to changes view
|
||||
const changesButton = screen.getByTestId('view-mode-changes');
|
||||
fireEvent.click(changesButton);
|
||||
|
||||
// Verify the changes button is clicked (it should have active class)
|
||||
await waitFor(() => {
|
||||
expect(changesButton).toHaveClass('bg-primary/20');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* Unit tests for AgentOutputModal responsive behavior
|
||||
*
|
||||
* These tests verify that Tailwind CSS responsive classes are correctly applied
|
||||
* to the modal across different viewport sizes (mobile, tablet, desktop).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { AgentOutputModal } from '../../../src/components/views/board-view/dialogs/agent-output-modal';
|
||||
import { useAppStore } from '@automaker/ui/store/app-store';
|
||||
import { useAgentOutput, useFeature } from '@automaker/ui/hooks/queries';
|
||||
import { getElectronAPI } from '@automaker/ui/lib/electron';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@automaker/ui/hooks/queries');
|
||||
vi.mock('@automaker/ui/lib/electron');
|
||||
vi.mock('@automaker/ui/store/app-store');
|
||||
|
||||
const mockUseAppStore = vi.mocked(useAppStore);
|
||||
const mockUseAgentOutput = vi.mocked(useAgentOutput);
|
||||
const mockUseFeature = vi.mocked(useFeature);
|
||||
const mockGetElectronAPI = vi.mocked(getElectronAPI);
|
||||
|
||||
describe('AgentOutputModal Responsive Behavior', () => {
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
featureDescription: 'Test feature description',
|
||||
featureId: 'test-feature-123',
|
||||
featureStatus: 'running',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Mock useAppStore
|
||||
mockUseAppStore.mockImplementation((selector) => {
|
||||
if (selector === 'state') {
|
||||
return { useWorktrees: false };
|
||||
}
|
||||
return selector({ useWorktrees: false });
|
||||
});
|
||||
|
||||
// Mock useAgentOutput
|
||||
mockUseAgentOutput.mockReturnValue({
|
||||
data: '',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
} as ReturnType<typeof useAgentOutput>);
|
||||
|
||||
// Mock useFeature
|
||||
mockUseFeature.mockReturnValue({
|
||||
data: null,
|
||||
refetch: vi.fn(),
|
||||
} as ReturnType<typeof useFeature>);
|
||||
|
||||
// Mock electron API
|
||||
mockGetElectronAPI.mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe('Mobile Screen (< 640px)', () => {
|
||||
it('should use full width on mobile screens', () => {
|
||||
// Set up viewport for mobile
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === '(max-width: 639px)',
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
render(<AgentOutputModal {...defaultProps} />);
|
||||
|
||||
// Find the DialogContent element
|
||||
const dialogContent = screen.getByTestId('agent-output-modal');
|
||||
// Base class should be present
|
||||
expect(dialogContent).toHaveClass('w-full');
|
||||
// In Tailwind, all responsive classes are always present on the element
|
||||
// The browser determines which ones apply based on viewport
|
||||
expect(dialogContent).toHaveClass('sm:w-[60vw]');
|
||||
});
|
||||
|
||||
it('should use max-w-[calc(100%-2rem)] on mobile', () => {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === '(max-width: 639px)',
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
render(<AgentOutputModal {...defaultProps} />);
|
||||
|
||||
const dialogContent = screen.getByTestId('agent-output-modal');
|
||||
expect(dialogContent).toHaveClass('max-w-[calc(100%-2rem)]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Small Screen (640px - < 768px)', () => {
|
||||
it('should use 60vw on small screens', () => {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === '(min-width: 640px) and (max-width: 767px)',
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
render(<AgentOutputModal {...defaultProps} />);
|
||||
|
||||
const dialogContent = screen.getByTestId('agent-output-modal');
|
||||
// At sm breakpoint, sm:w-[60vw] should be applied (takes precedence over w-full)
|
||||
expect(dialogContent).toHaveClass('sm:w-[60vw]');
|
||||
expect(dialogContent).toHaveClass('sm:max-w-[60vw]');
|
||||
});
|
||||
|
||||
it('should use 80vh height on small screens', () => {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === '(min-width: 640px) and (max-width: 767px)',
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
render(<AgentOutputModal {...defaultProps} />);
|
||||
|
||||
const dialogContent = screen.getByTestId('agent-output-modal');
|
||||
// At sm breakpoint, sm:max-h-[80vh] should be applied
|
||||
expect(dialogContent).toHaveClass('sm:max-h-[80vh]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tablet Screen (≥ 768px)', () => {
|
||||
it('should use sm responsive classes on tablet screens', () => {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === '(min-width: 768px)',
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
render(<AgentOutputModal {...defaultProps} />);
|
||||
|
||||
const dialogContent = screen.getByTestId('agent-output-modal');
|
||||
// sm: classes are present for responsive behavior
|
||||
expect(dialogContent).toHaveClass('sm:w-[60vw]');
|
||||
expect(dialogContent).toHaveClass('sm:max-w-[60vw]');
|
||||
expect(dialogContent).toHaveClass('sm:max-h-[80vh]');
|
||||
});
|
||||
|
||||
it('should use max-w constraint on tablet screens', () => {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === '(min-width: 768px)',
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
render(<AgentOutputModal {...defaultProps} />);
|
||||
|
||||
const dialogContent = screen.getByTestId('agent-output-modal');
|
||||
// sm: max-width class is present
|
||||
expect(dialogContent).toHaveClass('sm:max-w-[60vw]');
|
||||
});
|
||||
|
||||
it('should use 80vh height on tablet screens', () => {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === '(min-width: 768px)',
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
render(<AgentOutputModal {...defaultProps} />);
|
||||
|
||||
const dialogContent = screen.getByTestId('agent-output-modal');
|
||||
// sm: max-height class is present
|
||||
expect(dialogContent).toHaveClass('sm:max-h-[80vh]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Responsive behavior combinations', () => {
|
||||
it('should apply all responsive classes correctly', () => {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === '(min-width: 768px)',
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
render(<AgentOutputModal {...defaultProps} />);
|
||||
|
||||
const dialogContent = screen.getByTestId('agent-output-modal');
|
||||
|
||||
// Check base classes
|
||||
expect(dialogContent).toHaveClass('w-full');
|
||||
expect(dialogContent).toHaveClass('max-h-[85dvh]');
|
||||
expect(dialogContent).toHaveClass('max-w-[calc(100%-2rem)]');
|
||||
|
||||
// Check small screen classes
|
||||
expect(dialogContent).toHaveClass('sm:w-[60vw]');
|
||||
expect(dialogContent).toHaveClass('sm:max-w-[60vw]');
|
||||
expect(dialogContent).toHaveClass('sm:max-h-[80vh]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Modal closed state', () => {
|
||||
it('should not render when closed', () => {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === '(max-width: 639px)',
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
render(<AgentOutputModal {...defaultProps} open={false} />);
|
||||
|
||||
expect(screen.queryByTestId('agent-output-modal')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Viewport changes', () => {
|
||||
it('should update when window is resized', () => {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === '(max-width: 639px)',
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
const { rerender } = render(<AgentOutputModal {...defaultProps} />);
|
||||
|
||||
// Update to tablet size
|
||||
(window.matchMedia as ReturnType<typeof vi.fn>).mockImplementation((query: string) => ({
|
||||
matches: query === '(min-width: 768px)',
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
}));
|
||||
|
||||
// Simulate resize by re-rendering
|
||||
rerender(<AgentOutputModal {...defaultProps} />);
|
||||
|
||||
const dialogContent = screen.getByTestId('agent-output-modal');
|
||||
expect(dialogContent).toHaveClass('sm:w-[60vw]');
|
||||
});
|
||||
});
|
||||
});
|
||||
49
apps/ui/tests/unit/components/card-actions.test.tsx
Normal file
49
apps/ui/tests/unit/components/card-actions.test.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { CardActions } from '../../../src/components/views/board-view/components/kanban-card/card-actions';
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
describe('CardActions', () => {
|
||||
it('renders backlog logs button when context exists', () => {
|
||||
const feature = {
|
||||
id: 'feature-logs',
|
||||
status: 'backlog',
|
||||
error: undefined,
|
||||
} as unknown as Feature;
|
||||
|
||||
render(
|
||||
<CardActions
|
||||
feature={feature}
|
||||
isCurrentAutoTask={false}
|
||||
isRunningTask={false}
|
||||
hasContext
|
||||
onEdit={vi.fn()}
|
||||
onViewOutput={vi.fn()}
|
||||
onImplement={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('view-output-backlog-feature-logs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render backlog logs button without context', () => {
|
||||
const feature = {
|
||||
id: 'feature-no-logs',
|
||||
status: 'backlog',
|
||||
error: undefined,
|
||||
} as unknown as Feature;
|
||||
|
||||
render(
|
||||
<CardActions
|
||||
feature={feature}
|
||||
isCurrentAutoTask={false}
|
||||
isRunningTask={false}
|
||||
onEdit={vi.fn()}
|
||||
onViewOutput={vi.fn()}
|
||||
onImplement={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('view-output-backlog-feature-no-logs')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
39
apps/ui/tests/unit/components/card-badges.test.tsx
Normal file
39
apps/ui/tests/unit/components/card-badges.test.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { CardBadges } from '../../../src/components/views/board-view/components/kanban-card/card-badges';
|
||||
import { TooltipProvider } from '../../../src/components/ui/tooltip';
|
||||
import type { Feature } from '@automaker/types';
|
||||
|
||||
describe('CardBadges', () => {
|
||||
it('renders merge conflict warning badge when status is merge_conflict', () => {
|
||||
const feature = {
|
||||
id: 'feature-1',
|
||||
status: 'merge_conflict',
|
||||
error: undefined,
|
||||
} as unknown as Feature;
|
||||
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<CardBadges feature={feature} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('merge-conflict-badge-feature-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render badges when there is no error and no merge conflict', () => {
|
||||
const feature = {
|
||||
id: 'feature-2',
|
||||
status: 'backlog',
|
||||
error: undefined,
|
||||
} as unknown as Feature;
|
||||
|
||||
const { container } = render(
|
||||
<TooltipProvider>
|
||||
<CardBadges feature={feature} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
321
apps/ui/tests/unit/components/event-content-formatter.test.ts
Normal file
321
apps/ui/tests/unit/components/event-content-formatter.test.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* Tests for event-content-formatter utility
|
||||
* Verifies correct formatting of AutoModeEvent and BacklogPlanEvent content
|
||||
* for display in the AgentOutputModal.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
formatAutoModeEventContent,
|
||||
formatBacklogPlanEventContent,
|
||||
} from '../../../src/components/views/board-view/dialogs/event-content-formatter';
|
||||
import type { AutoModeEvent } from '@/types/electron';
|
||||
import type { BacklogPlanEvent } from '@automaker/types';
|
||||
|
||||
describe('formatAutoModeEventContent', () => {
|
||||
describe('auto_mode_progress', () => {
|
||||
it('should return content string', () => {
|
||||
const event = { type: 'auto_mode_progress', content: 'Processing step 1' } as AutoModeEvent;
|
||||
expect(formatAutoModeEventContent(event)).toBe('Processing step 1');
|
||||
});
|
||||
|
||||
it('should return empty string when content is undefined', () => {
|
||||
const event = { type: 'auto_mode_progress' } as AutoModeEvent;
|
||||
expect(formatAutoModeEventContent(event)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto_mode_tool', () => {
|
||||
it('should format tool name and input', () => {
|
||||
const event = {
|
||||
type: 'auto_mode_tool',
|
||||
tool: 'Read',
|
||||
input: { file: 'test.ts' },
|
||||
} as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('🔧 Tool: Read');
|
||||
expect(result).toContain('"file": "test.ts"');
|
||||
});
|
||||
|
||||
it('should handle missing tool name', () => {
|
||||
const event = { type: 'auto_mode_tool' } as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('Unknown Tool');
|
||||
});
|
||||
|
||||
it('should handle missing input', () => {
|
||||
const event = { type: 'auto_mode_tool', tool: 'Write' } as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('🔧 Tool: Write');
|
||||
expect(result).not.toContain('Input:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto_mode_phase', () => {
|
||||
it('should use planning emoji for planning phase', () => {
|
||||
const event = {
|
||||
type: 'auto_mode_phase',
|
||||
phase: 'planning',
|
||||
message: 'Starting plan',
|
||||
} as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('📋');
|
||||
expect(result).toContain('Starting plan');
|
||||
});
|
||||
|
||||
it('should use action emoji for action phase', () => {
|
||||
const event = {
|
||||
type: 'auto_mode_phase',
|
||||
phase: 'action',
|
||||
message: 'Executing',
|
||||
} as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('⚡');
|
||||
});
|
||||
|
||||
it('should use checkmark emoji for other phases', () => {
|
||||
const event = {
|
||||
type: 'auto_mode_phase',
|
||||
phase: 'complete',
|
||||
message: 'Done',
|
||||
} as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('✅');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto_mode_error', () => {
|
||||
it('should format error message', () => {
|
||||
const event = {
|
||||
type: 'auto_mode_error',
|
||||
error: 'Something went wrong',
|
||||
} as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('❌ Error: Something went wrong');
|
||||
});
|
||||
});
|
||||
|
||||
describe('planning events', () => {
|
||||
it('should format planning_started with mode label', () => {
|
||||
const event = {
|
||||
type: 'planning_started',
|
||||
mode: 'lite',
|
||||
message: 'Starting lite planning',
|
||||
} as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('Planning Mode: Lite');
|
||||
expect(result).toContain('Starting lite planning');
|
||||
});
|
||||
|
||||
it('should format spec planning mode', () => {
|
||||
const event = {
|
||||
type: 'planning_started',
|
||||
mode: 'spec',
|
||||
message: 'Starting spec planning',
|
||||
} as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('Planning Mode: Spec');
|
||||
});
|
||||
|
||||
it('should format full planning mode', () => {
|
||||
const event = {
|
||||
type: 'planning_started',
|
||||
mode: 'full',
|
||||
message: 'Starting full planning',
|
||||
} as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('Planning Mode: Full');
|
||||
});
|
||||
|
||||
it('should format plan_approval_required', () => {
|
||||
const event = { type: 'plan_approval_required' } as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('waiting for your approval');
|
||||
});
|
||||
|
||||
it('should format plan_approved without edits', () => {
|
||||
const event = { type: 'plan_approved', hasEdits: false } as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('Plan approved');
|
||||
expect(result).not.toContain('with edits');
|
||||
});
|
||||
|
||||
it('should format plan_approved with edits', () => {
|
||||
const event = { type: 'plan_approved', hasEdits: true } as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('Plan approved (with edits)');
|
||||
});
|
||||
|
||||
it('should format plan_auto_approved', () => {
|
||||
const event = { type: 'plan_auto_approved' } as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('Plan auto-approved');
|
||||
});
|
||||
|
||||
it('should format plan_revision_requested', () => {
|
||||
const event = {
|
||||
type: 'plan_revision_requested',
|
||||
planVersion: 3,
|
||||
} as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('Revising plan');
|
||||
expect(result).toContain('v3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('task events', () => {
|
||||
it('should format auto_mode_task_started', () => {
|
||||
const event = {
|
||||
type: 'auto_mode_task_started',
|
||||
taskId: 'task-1',
|
||||
taskDescription: 'Write tests',
|
||||
} as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('Starting task-1: Write tests');
|
||||
});
|
||||
|
||||
it('should format auto_mode_task_complete', () => {
|
||||
const event = {
|
||||
type: 'auto_mode_task_complete',
|
||||
taskId: 'task-1',
|
||||
tasksCompleted: 3,
|
||||
tasksTotal: 5,
|
||||
} as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('task-1 completed (3/5)');
|
||||
});
|
||||
|
||||
it('should format auto_mode_phase_complete', () => {
|
||||
const event = {
|
||||
type: 'auto_mode_phase_complete',
|
||||
phaseNumber: 2,
|
||||
} as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('Phase 2 complete');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto_mode_feature_complete', () => {
|
||||
it('should show success emoji when passes is true', () => {
|
||||
const event = {
|
||||
type: 'auto_mode_feature_complete',
|
||||
passes: true,
|
||||
message: 'All tests pass',
|
||||
} as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('✅');
|
||||
expect(result).toContain('All tests pass');
|
||||
});
|
||||
|
||||
it('should show warning emoji when passes is false', () => {
|
||||
const event = {
|
||||
type: 'auto_mode_feature_complete',
|
||||
passes: false,
|
||||
message: 'Some tests failed',
|
||||
} as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('⚠️');
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto_mode_ultrathink_preparation', () => {
|
||||
it('should format with warnings', () => {
|
||||
const event = {
|
||||
type: 'auto_mode_ultrathink_preparation',
|
||||
warnings: ['High cost', 'Long runtime'],
|
||||
} as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('Ultrathink Preparation');
|
||||
expect(result).toContain('Warnings:');
|
||||
expect(result).toContain('High cost');
|
||||
expect(result).toContain('Long runtime');
|
||||
});
|
||||
|
||||
it('should format with recommendations', () => {
|
||||
const event = {
|
||||
type: 'auto_mode_ultrathink_preparation',
|
||||
recommendations: ['Use caching', 'Reduce scope'],
|
||||
} as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('Recommendations:');
|
||||
expect(result).toContain('Use caching');
|
||||
});
|
||||
|
||||
it('should format estimated cost', () => {
|
||||
const event = {
|
||||
type: 'auto_mode_ultrathink_preparation',
|
||||
estimatedCost: 1.5,
|
||||
} as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('Estimated Cost: ~$1.50');
|
||||
});
|
||||
|
||||
it('should format estimated time', () => {
|
||||
const event = {
|
||||
type: 'auto_mode_ultrathink_preparation',
|
||||
estimatedTime: '5-10 minutes',
|
||||
} as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('Estimated Time: 5-10 minutes');
|
||||
});
|
||||
|
||||
it('should handle event with no optional fields', () => {
|
||||
const event = {
|
||||
type: 'auto_mode_ultrathink_preparation',
|
||||
} as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toContain('Ultrathink Preparation');
|
||||
expect(result).not.toContain('Warnings:');
|
||||
expect(result).not.toContain('Recommendations:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown event type', () => {
|
||||
it('should return empty string for unknown event types', () => {
|
||||
const event = { type: 'unknown_type' } as unknown as AutoModeEvent;
|
||||
const result = formatAutoModeEventContent(event);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBacklogPlanEventContent', () => {
|
||||
it('should format backlog_plan_progress', () => {
|
||||
const event = { type: 'backlog_plan_progress', content: 'Analyzing features' };
|
||||
const result = formatBacklogPlanEventContent(event as BacklogPlanEvent);
|
||||
expect(result).toContain('🧭');
|
||||
expect(result).toContain('Analyzing features');
|
||||
});
|
||||
|
||||
it('should handle missing content in progress event', () => {
|
||||
const event = { type: 'backlog_plan_progress' };
|
||||
const result = formatBacklogPlanEventContent(event as BacklogPlanEvent);
|
||||
expect(result).toContain('Backlog plan progress update');
|
||||
});
|
||||
|
||||
it('should format backlog_plan_error', () => {
|
||||
const event = { type: 'backlog_plan_error', error: 'API failure' };
|
||||
const result = formatBacklogPlanEventContent(event as BacklogPlanEvent);
|
||||
expect(result).toContain('❌');
|
||||
expect(result).toContain('API failure');
|
||||
});
|
||||
|
||||
it('should handle missing error message', () => {
|
||||
const event = { type: 'backlog_plan_error' };
|
||||
const result = formatBacklogPlanEventContent(event as BacklogPlanEvent);
|
||||
expect(result).toContain('Unknown error');
|
||||
});
|
||||
|
||||
it('should format backlog_plan_complete', () => {
|
||||
const event = { type: 'backlog_plan_complete' };
|
||||
const result = formatBacklogPlanEventContent(event as BacklogPlanEvent);
|
||||
expect(result).toContain('✅');
|
||||
expect(result).toContain('Backlog plan completed');
|
||||
});
|
||||
|
||||
it('should format unknown backlog event type', () => {
|
||||
const event = { type: 'some_other_event' };
|
||||
const result = formatBacklogPlanEventContent(event as BacklogPlanEvent);
|
||||
expect(result).toContain('some_other_event');
|
||||
});
|
||||
});
|
||||
524
apps/ui/tests/unit/components/feature-creation-defaults.test.ts
Normal file
524
apps/ui/tests/unit/components/feature-creation-defaults.test.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* Tests for default fields on auto-created features
|
||||
*
|
||||
* Verifies that features created from PR review comments, GitHub issues,
|
||||
* and quick templates include required default fields:
|
||||
* - planningMode: 'skip'
|
||||
* - requirePlanApproval: false
|
||||
* - dependencies: []
|
||||
* - prUrl: set when PR URL is available
|
||||
*
|
||||
* These tests validate the feature object construction patterns used across
|
||||
* multiple UI creation paths to ensure consistency.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveModelString } from '@automaker/model-resolver';
|
||||
|
||||
// ============================================
|
||||
// Feature construction helpers that mirror the actual creation logic
|
||||
// in the source components. These intentionally duplicate the object-construction
|
||||
// patterns from the components so that any deviation in the source will
|
||||
// require a deliberate update to the corresponding builder here.
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Constructs a feature object as done by handleAutoAddressComments in github-prs-view.tsx
|
||||
*/
|
||||
function buildPRAutoAddressFeature(pr: { number: number; url?: string; headRefName?: string }) {
|
||||
const featureId = `pr-${pr.number}-test-uuid`;
|
||||
return {
|
||||
id: featureId,
|
||||
title: `Address PR #${pr.number} Review Comments`,
|
||||
category: 'bug-fix',
|
||||
description: `Read the review requests on PR #${pr.number} and address any feedback the best you can.`,
|
||||
steps: [],
|
||||
status: 'backlog',
|
||||
model: resolveModelString('opus'),
|
||||
thinkingLevel: 'none',
|
||||
planningMode: 'skip',
|
||||
requirePlanApproval: false,
|
||||
dependencies: [],
|
||||
...(pr.url ? { prUrl: pr.url } : {}),
|
||||
...(pr.headRefName ? { branchName: pr.headRefName } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a feature object as done by handleSubmit('together') in pr-comment-resolution-dialog.tsx
|
||||
*/
|
||||
function buildPRCommentResolutionGroupFeature(
|
||||
pr: {
|
||||
number: number;
|
||||
title: string;
|
||||
url?: string;
|
||||
headRefName?: string;
|
||||
},
|
||||
commentCount = 2
|
||||
) {
|
||||
return {
|
||||
id: 'test-uuid',
|
||||
title: `Address ${commentCount} review comment${commentCount > 1 ? 's' : ''} on PR #${pr.number}`,
|
||||
category: 'bug-fix',
|
||||
description: `PR Review Comments for #${pr.number}`,
|
||||
steps: [],
|
||||
status: 'backlog',
|
||||
model: resolveModelString('opus'),
|
||||
thinkingLevel: 'none',
|
||||
reasoningEffort: undefined,
|
||||
providerId: undefined,
|
||||
planningMode: 'skip',
|
||||
requirePlanApproval: false,
|
||||
dependencies: [],
|
||||
...(pr.url ? { prUrl: pr.url } : {}),
|
||||
...(pr.headRefName ? { branchName: pr.headRefName } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a feature object as done by handleSubmit('individually') in pr-comment-resolution-dialog.tsx
|
||||
*/
|
||||
function buildPRCommentResolutionIndividualFeature(pr: {
|
||||
number: number;
|
||||
title: string;
|
||||
url?: string;
|
||||
headRefName?: string;
|
||||
}) {
|
||||
return {
|
||||
id: 'test-uuid',
|
||||
title: `Address PR #${pr.number} comment by @reviewer on file.ts:10`,
|
||||
category: 'bug-fix',
|
||||
description: `Single PR comment resolution`,
|
||||
steps: [],
|
||||
status: 'backlog',
|
||||
model: resolveModelString('opus'),
|
||||
thinkingLevel: 'none',
|
||||
reasoningEffort: undefined,
|
||||
providerId: undefined,
|
||||
planningMode: 'skip',
|
||||
requirePlanApproval: false,
|
||||
dependencies: [],
|
||||
...(pr.url ? { prUrl: pr.url } : {}),
|
||||
...(pr.headRefName ? { branchName: pr.headRefName } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a feature object as done by handleConvertToTask in github-issues-view.tsx
|
||||
*/
|
||||
function buildGitHubIssueConvertFeature(
|
||||
issue: {
|
||||
number: number;
|
||||
title: string;
|
||||
},
|
||||
currentBranch: string
|
||||
) {
|
||||
return {
|
||||
id: `issue-${issue.number}-test-uuid`,
|
||||
title: issue.title,
|
||||
description: `From GitHub Issue #${issue.number}`,
|
||||
category: 'From GitHub',
|
||||
status: 'backlog' as const,
|
||||
passes: false,
|
||||
priority: 2,
|
||||
model: resolveModelString('opus'),
|
||||
thinkingLevel: 'none' as const,
|
||||
branchName: currentBranch,
|
||||
planningMode: 'skip' as const,
|
||||
requirePlanApproval: false,
|
||||
dependencies: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a feature object as done by handleAddFeatureFromIssue in github-issues-view.tsx
|
||||
*/
|
||||
function buildGitHubIssueDialogFeature(
|
||||
issue: {
|
||||
number: number;
|
||||
},
|
||||
featureData: {
|
||||
title: string;
|
||||
planningMode: string;
|
||||
requirePlanApproval: boolean;
|
||||
workMode: string;
|
||||
branchName: string;
|
||||
},
|
||||
currentBranch: string
|
||||
) {
|
||||
return {
|
||||
id: `issue-${issue.number}-test-uuid`,
|
||||
title: featureData.title,
|
||||
description: 'Test description',
|
||||
category: 'test-category',
|
||||
status: 'backlog' as const,
|
||||
passes: false,
|
||||
priority: 2,
|
||||
model: 'claude-opus-4-6',
|
||||
thinkingLevel: 'none',
|
||||
reasoningEffort: 'none',
|
||||
skipTests: false,
|
||||
branchName: featureData.workMode === 'current' ? currentBranch : featureData.branchName,
|
||||
planningMode: featureData.planningMode,
|
||||
requirePlanApproval: featureData.requirePlanApproval,
|
||||
dependencies: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a feature data object as done by handleAutoAddressPRComments in board-view.tsx
|
||||
*/
|
||||
function buildBoardViewAutoAddressPRFeature(
|
||||
worktree: {
|
||||
branch: string;
|
||||
},
|
||||
prInfo: {
|
||||
number: number;
|
||||
url?: string;
|
||||
}
|
||||
) {
|
||||
return {
|
||||
title: `Address PR #${prInfo.number} Review Comments`,
|
||||
category: 'Maintenance',
|
||||
description: `Read the review requests on PR #${prInfo.number} and address any feedback the best you can.`,
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
skipTests: false,
|
||||
model: resolveModelString('opus'),
|
||||
thinkingLevel: 'none' as const,
|
||||
branchName: worktree.branch,
|
||||
workMode: 'custom' as const,
|
||||
priority: 1,
|
||||
planningMode: 'skip' as const,
|
||||
requirePlanApproval: false,
|
||||
dependencies: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Tests
|
||||
// ============================================
|
||||
|
||||
describe('Feature creation default fields', () => {
|
||||
describe('PR auto-address feature (github-prs-view)', () => {
|
||||
it('should include planningMode: "skip"', () => {
|
||||
const feature = buildPRAutoAddressFeature({ number: 42 });
|
||||
expect(feature.planningMode).toBe('skip');
|
||||
});
|
||||
|
||||
it('should include requirePlanApproval: false', () => {
|
||||
const feature = buildPRAutoAddressFeature({ number: 42 });
|
||||
expect(feature.requirePlanApproval).toBe(false);
|
||||
});
|
||||
|
||||
it('should include dependencies: []', () => {
|
||||
const feature = buildPRAutoAddressFeature({ number: 42 });
|
||||
expect(feature.dependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it('should set prUrl when PR has a URL', () => {
|
||||
const feature = buildPRAutoAddressFeature({
|
||||
number: 42,
|
||||
url: 'https://github.com/org/repo/pull/42',
|
||||
});
|
||||
expect(feature.prUrl).toBe('https://github.com/org/repo/pull/42');
|
||||
});
|
||||
|
||||
it('should not include prUrl when PR has no URL', () => {
|
||||
const feature = buildPRAutoAddressFeature({ number: 42 });
|
||||
expect(feature).not.toHaveProperty('prUrl');
|
||||
});
|
||||
|
||||
it('should set branchName from headRefName when present', () => {
|
||||
const feature = buildPRAutoAddressFeature({
|
||||
number: 42,
|
||||
headRefName: 'feature/my-pr',
|
||||
});
|
||||
expect(feature.branchName).toBe('feature/my-pr');
|
||||
});
|
||||
|
||||
it('should not include branchName when headRefName is absent', () => {
|
||||
const feature = buildPRAutoAddressFeature({ number: 42 });
|
||||
expect(feature).not.toHaveProperty('branchName');
|
||||
});
|
||||
|
||||
it('should set status to backlog', () => {
|
||||
const feature = buildPRAutoAddressFeature({ number: 42 });
|
||||
expect(feature.status).toBe('backlog');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PR comment resolution - group mode (pr-comment-resolution-dialog)', () => {
|
||||
const basePR = { number: 99, title: 'Fix thing' };
|
||||
|
||||
it('should include planningMode: "skip"', () => {
|
||||
const feature = buildPRCommentResolutionGroupFeature(basePR);
|
||||
expect(feature.planningMode).toBe('skip');
|
||||
});
|
||||
|
||||
it('should include requirePlanApproval: false', () => {
|
||||
const feature = buildPRCommentResolutionGroupFeature(basePR);
|
||||
expect(feature.requirePlanApproval).toBe(false);
|
||||
});
|
||||
|
||||
it('should include dependencies: []', () => {
|
||||
const feature = buildPRCommentResolutionGroupFeature(basePR);
|
||||
expect(feature.dependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it('should set prUrl when PR has a URL', () => {
|
||||
const feature = buildPRCommentResolutionGroupFeature({
|
||||
...basePR,
|
||||
url: 'https://github.com/org/repo/pull/99',
|
||||
});
|
||||
expect(feature.prUrl).toBe('https://github.com/org/repo/pull/99');
|
||||
});
|
||||
|
||||
it('should not set prUrl when PR has no URL', () => {
|
||||
const feature = buildPRCommentResolutionGroupFeature(basePR);
|
||||
expect(feature).not.toHaveProperty('prUrl');
|
||||
});
|
||||
|
||||
it('should set branchName from headRefName when present', () => {
|
||||
const feature = buildPRCommentResolutionGroupFeature({
|
||||
...basePR,
|
||||
headRefName: 'fix/thing',
|
||||
});
|
||||
expect(feature.branchName).toBe('fix/thing');
|
||||
});
|
||||
|
||||
it('should pluralize title correctly for single vs multiple comments', () => {
|
||||
const singleComment = buildPRCommentResolutionGroupFeature(basePR, 1);
|
||||
const multipleComments = buildPRCommentResolutionGroupFeature(basePR, 5);
|
||||
|
||||
expect(singleComment.title).toBe(`Address 1 review comment on PR #${basePR.number}`);
|
||||
expect(multipleComments.title).toBe(`Address 5 review comments on PR #${basePR.number}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PR comment resolution - individual mode (pr-comment-resolution-dialog)', () => {
|
||||
const basePR = { number: 55, title: 'Add feature' };
|
||||
|
||||
it('should include planningMode: "skip"', () => {
|
||||
const feature = buildPRCommentResolutionIndividualFeature(basePR);
|
||||
expect(feature.planningMode).toBe('skip');
|
||||
});
|
||||
|
||||
it('should include requirePlanApproval: false', () => {
|
||||
const feature = buildPRCommentResolutionIndividualFeature(basePR);
|
||||
expect(feature.requirePlanApproval).toBe(false);
|
||||
});
|
||||
|
||||
it('should include dependencies: []', () => {
|
||||
const feature = buildPRCommentResolutionIndividualFeature(basePR);
|
||||
expect(feature.dependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it('should set prUrl when PR has a URL', () => {
|
||||
const feature = buildPRCommentResolutionIndividualFeature({
|
||||
...basePR,
|
||||
url: 'https://github.com/org/repo/pull/55',
|
||||
});
|
||||
expect(feature.prUrl).toBe('https://github.com/org/repo/pull/55');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitHub issue quick convert (github-issues-view)', () => {
|
||||
const issue = { number: 123, title: 'Fix bug' };
|
||||
|
||||
it('should include planningMode: "skip"', () => {
|
||||
const feature = buildGitHubIssueConvertFeature(issue, 'main');
|
||||
expect(feature.planningMode).toBe('skip');
|
||||
});
|
||||
|
||||
it('should include requirePlanApproval: false', () => {
|
||||
const feature = buildGitHubIssueConvertFeature(issue, 'main');
|
||||
expect(feature.requirePlanApproval).toBe(false);
|
||||
});
|
||||
|
||||
it('should include dependencies: []', () => {
|
||||
const feature = buildGitHubIssueConvertFeature(issue, 'main');
|
||||
expect(feature.dependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it('should set branchName to current branch', () => {
|
||||
const feature = buildGitHubIssueConvertFeature(issue, 'feature/my-branch');
|
||||
expect(feature.branchName).toBe('feature/my-branch');
|
||||
});
|
||||
|
||||
it('should set status to backlog', () => {
|
||||
const feature = buildGitHubIssueConvertFeature(issue, 'main');
|
||||
expect(feature.status).toBe('backlog');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GitHub issue dialog creation (github-issues-view)', () => {
|
||||
const issue = { number: 456 };
|
||||
|
||||
it('should include dependencies: [] regardless of dialog data', () => {
|
||||
const feature = buildGitHubIssueDialogFeature(
|
||||
issue,
|
||||
{
|
||||
title: 'Test',
|
||||
planningMode: 'full',
|
||||
requirePlanApproval: true,
|
||||
workMode: 'custom',
|
||||
branchName: 'feat/test',
|
||||
},
|
||||
'main'
|
||||
);
|
||||
expect(feature.dependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it('should preserve planningMode from dialog (not override)', () => {
|
||||
const feature = buildGitHubIssueDialogFeature(
|
||||
issue,
|
||||
{
|
||||
title: 'Test',
|
||||
planningMode: 'full',
|
||||
requirePlanApproval: true,
|
||||
workMode: 'custom',
|
||||
branchName: 'feat/test',
|
||||
},
|
||||
'main'
|
||||
);
|
||||
// Dialog-provided values are preserved (not overridden to 'skip')
|
||||
expect(feature.planningMode).toBe('full');
|
||||
expect(feature.requirePlanApproval).toBe(true);
|
||||
});
|
||||
|
||||
it('should use currentBranch when workMode is "current"', () => {
|
||||
const feature = buildGitHubIssueDialogFeature(
|
||||
issue,
|
||||
{
|
||||
title: 'Test',
|
||||
planningMode: 'skip',
|
||||
requirePlanApproval: false,
|
||||
workMode: 'current',
|
||||
branchName: 'feat/custom',
|
||||
},
|
||||
'main'
|
||||
);
|
||||
expect(feature.branchName).toBe('main');
|
||||
});
|
||||
|
||||
it('should use provided branchName when workMode is not "current"', () => {
|
||||
const feature = buildGitHubIssueDialogFeature(
|
||||
issue,
|
||||
{
|
||||
title: 'Test',
|
||||
planningMode: 'skip',
|
||||
requirePlanApproval: false,
|
||||
workMode: 'custom',
|
||||
branchName: 'feat/custom',
|
||||
},
|
||||
'main'
|
||||
);
|
||||
expect(feature.branchName).toBe('feat/custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Board view auto-address PR comments (board-view)', () => {
|
||||
const worktree = { branch: 'feature/my-feature' };
|
||||
const prInfo = { number: 77, url: 'https://github.com/org/repo/pull/77' };
|
||||
|
||||
it('should include planningMode: "skip"', () => {
|
||||
const featureData = buildBoardViewAutoAddressPRFeature(worktree, prInfo);
|
||||
expect(featureData.planningMode).toBe('skip');
|
||||
});
|
||||
|
||||
it('should include requirePlanApproval: false', () => {
|
||||
const featureData = buildBoardViewAutoAddressPRFeature(worktree, prInfo);
|
||||
expect(featureData.requirePlanApproval).toBe(false);
|
||||
});
|
||||
|
||||
it('should include dependencies: []', () => {
|
||||
const featureData = buildBoardViewAutoAddressPRFeature(worktree, prInfo);
|
||||
expect(featureData.dependencies).toEqual([]);
|
||||
});
|
||||
|
||||
it('should set branchName from worktree', () => {
|
||||
const featureData = buildBoardViewAutoAddressPRFeature(worktree, prInfo);
|
||||
expect(featureData.branchName).toBe('feature/my-feature');
|
||||
});
|
||||
|
||||
it('should set workMode to "custom"', () => {
|
||||
const featureData = buildBoardViewAutoAddressPRFeature(worktree, prInfo);
|
||||
expect(featureData.workMode).toBe('custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cross-path consistency', () => {
|
||||
// Shared fixture: build one feature from each auto-creation path
|
||||
function buildAllAutoCreatedFeatures() {
|
||||
return {
|
||||
prAutoAddress: buildPRAutoAddressFeature({ number: 1 }),
|
||||
commentGroup: buildPRCommentResolutionGroupFeature({ number: 2, title: 'PR' }),
|
||||
commentIndividual: buildPRCommentResolutionIndividualFeature({ number: 3, title: 'PR' }),
|
||||
issueConvert: buildGitHubIssueConvertFeature({ number: 4, title: 'Issue' }, 'main'),
|
||||
boardAutoAddress: buildBoardViewAutoAddressPRFeature({ branch: 'main' }, { number: 5 }),
|
||||
};
|
||||
}
|
||||
|
||||
it('all auto-creation paths should include planningMode: "skip"', () => {
|
||||
const features = buildAllAutoCreatedFeatures();
|
||||
for (const [path, feature] of Object.entries(features)) {
|
||||
expect(feature.planningMode, `${path} should have planningMode: "skip"`).toBe('skip');
|
||||
}
|
||||
});
|
||||
|
||||
it('all auto-creation paths should include requirePlanApproval: false', () => {
|
||||
const features = buildAllAutoCreatedFeatures();
|
||||
for (const [path, feature] of Object.entries(features)) {
|
||||
expect(feature.requirePlanApproval, `${path} should have requirePlanApproval: false`).toBe(
|
||||
false
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('all auto-creation paths should include dependencies: []', () => {
|
||||
const features = buildAllAutoCreatedFeatures();
|
||||
for (const [path, feature] of Object.entries(features)) {
|
||||
expect(feature.dependencies, `${path} should have dependencies: []`).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('PR-related paths should set prUrl when URL is available', () => {
|
||||
const prFeature = buildPRAutoAddressFeature({
|
||||
number: 1,
|
||||
url: 'https://github.com/org/repo/pull/1',
|
||||
});
|
||||
const commentGroupFeature = buildPRCommentResolutionGroupFeature({
|
||||
number: 2,
|
||||
title: 'PR',
|
||||
url: 'https://github.com/org/repo/pull/2',
|
||||
});
|
||||
const commentIndividualFeature = buildPRCommentResolutionIndividualFeature({
|
||||
number: 3,
|
||||
title: 'PR',
|
||||
url: 'https://github.com/org/repo/pull/3',
|
||||
});
|
||||
|
||||
expect(prFeature.prUrl).toBe('https://github.com/org/repo/pull/1');
|
||||
expect(commentGroupFeature.prUrl).toBe('https://github.com/org/repo/pull/2');
|
||||
expect(commentIndividualFeature.prUrl).toBe('https://github.com/org/repo/pull/3');
|
||||
});
|
||||
|
||||
it('PR-related paths should NOT include prUrl when URL is absent', () => {
|
||||
const prFeature = buildPRAutoAddressFeature({ number: 1 });
|
||||
const commentGroupFeature = buildPRCommentResolutionGroupFeature({ number: 2, title: 'PR' });
|
||||
const commentIndividualFeature = buildPRCommentResolutionIndividualFeature({
|
||||
number: 3,
|
||||
title: 'PR',
|
||||
});
|
||||
|
||||
expect(prFeature).not.toHaveProperty('prUrl');
|
||||
expect(commentGroupFeature).not.toHaveProperty('prUrl');
|
||||
expect(commentIndividualFeature).not.toHaveProperty('prUrl');
|
||||
});
|
||||
});
|
||||
});
|
||||
414
apps/ui/tests/unit/components/mobile-terminal-shortcuts.test.tsx
Normal file
414
apps/ui/tests/unit/components/mobile-terminal-shortcuts.test.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
/**
|
||||
* Unit tests for MobileTerminalShortcuts component
|
||||
* These tests verify the terminal shortcuts bar functionality and responsive behavior
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MobileTerminalShortcuts } from '../../../src/components/views/terminal-view/mobile-terminal-shortcuts.tsx';
|
||||
import type { StickyModifier } from '../../../src/components/views/terminal-view/sticky-modifier-keys.tsx';
|
||||
|
||||
// Mock the StickyModifierKeys component
|
||||
vi.mock('../../../src/components/views/terminal-view/sticky-modifier-keys.tsx', () => ({
|
||||
StickyModifierKeys: ({
|
||||
activeModifier,
|
||||
onModifierChange,
|
||||
isConnected,
|
||||
}: {
|
||||
activeModifier: StickyModifier;
|
||||
onModifierChange: (m: StickyModifier) => void;
|
||||
isConnected: boolean;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="sticky-modifier-keys"
|
||||
data-modifier={activeModifier}
|
||||
data-connected={isConnected}
|
||||
>
|
||||
<button onClick={() => onModifierChange('ctrl')} data-testid="ctrl-btn">
|
||||
Ctrl
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Helper to get arrow button by direction using the Lucide icon class
|
||||
*/
|
||||
function getArrowButton(direction: 'up' | 'down' | 'left' | 'right'): HTMLButtonElement | null {
|
||||
const iconClass = `lucide-arrow-${direction}`;
|
||||
const svg = document.querySelector(`svg.${iconClass}`);
|
||||
return (svg?.closest('button') as HTMLButtonElement) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates default props for MobileTerminalShortcuts component
|
||||
*/
|
||||
function createDefaultProps(overrides: Partial<typeof defaultProps> = {}) {
|
||||
return {
|
||||
...defaultProps,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
onSendInput: vi.fn(),
|
||||
isConnected: true,
|
||||
activeModifier: null as StickyModifier,
|
||||
onModifierChange: vi.fn(),
|
||||
onCopy: vi.fn(),
|
||||
onPaste: vi.fn(),
|
||||
onSelectAll: vi.fn(),
|
||||
onToggleSelectMode: vi.fn(),
|
||||
isSelectMode: false,
|
||||
};
|
||||
|
||||
describe('MobileTerminalShortcuts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the shortcuts bar with all buttons', () => {
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps()} />);
|
||||
|
||||
// Check for collapse button
|
||||
expect(screen.getByTitle('Hide shortcuts')).toBeInTheDocument();
|
||||
|
||||
// Check for sticky modifier keys
|
||||
expect(screen.getByTestId('sticky-modifier-keys')).toBeInTheDocument();
|
||||
|
||||
// Check for special keys
|
||||
expect(screen.getByText('Esc')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tab')).toBeInTheDocument();
|
||||
|
||||
// Check for Ctrl shortcuts
|
||||
expect(screen.getByText('^C')).toBeInTheDocument();
|
||||
expect(screen.getByText('^Z')).toBeInTheDocument();
|
||||
expect(screen.getByText('^B')).toBeInTheDocument();
|
||||
|
||||
// Check for arrow buttons via SVG icons
|
||||
expect(getArrowButton('left')).not.toBeNull();
|
||||
expect(getArrowButton('down')).not.toBeNull();
|
||||
expect(getArrowButton('up')).not.toBeNull();
|
||||
expect(getArrowButton('right')).not.toBeNull();
|
||||
|
||||
// Check for navigation keys
|
||||
expect(screen.getByText('Del')).toBeInTheDocument();
|
||||
expect(screen.getByText('Home')).toBeInTheDocument();
|
||||
expect(screen.getByText('End')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render clipboard action buttons when callbacks provided', () => {
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps()} />);
|
||||
|
||||
expect(screen.getByTitle('Select text')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Select all')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Copy selection')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Paste from clipboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render clipboard buttons when callbacks are not provided', () => {
|
||||
render(
|
||||
<MobileTerminalShortcuts
|
||||
{...createDefaultProps({
|
||||
onCopy: undefined,
|
||||
onPaste: undefined,
|
||||
onSelectAll: undefined,
|
||||
onToggleSelectMode: undefined,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTitle('Select text')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Select all')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Copy selection')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle('Paste from clipboard')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render in collapsed state when collapsed', () => {
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps()} />);
|
||||
|
||||
// Click collapse button
|
||||
fireEvent.click(screen.getByTitle('Hide shortcuts'));
|
||||
|
||||
// Should show collapsed view
|
||||
expect(screen.getByText('Shortcuts')).toBeInTheDocument();
|
||||
expect(screen.getByTitle('Show shortcuts')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Esc')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should expand when clicking show shortcuts button', () => {
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps()} />);
|
||||
|
||||
// Collapse first
|
||||
fireEvent.click(screen.getByTitle('Hide shortcuts'));
|
||||
expect(screen.queryByText('Esc')).not.toBeInTheDocument();
|
||||
|
||||
// Expand
|
||||
fireEvent.click(screen.getByTitle('Show shortcuts'));
|
||||
expect(screen.getByText('Esc')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Special Keys', () => {
|
||||
it('should send Escape key when Esc button is pressed', () => {
|
||||
const onSendInput = vi.fn();
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ onSendInput })} />);
|
||||
|
||||
const escButton = screen.getByText('Esc');
|
||||
fireEvent.pointerDown(escButton);
|
||||
|
||||
expect(onSendInput).toHaveBeenCalledWith('\x1b');
|
||||
});
|
||||
|
||||
it('should send Tab key when Tab button is pressed', () => {
|
||||
const onSendInput = vi.fn();
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ onSendInput })} />);
|
||||
|
||||
const tabButton = screen.getByText('Tab');
|
||||
fireEvent.pointerDown(tabButton);
|
||||
|
||||
expect(onSendInput).toHaveBeenCalledWith('\t');
|
||||
});
|
||||
|
||||
it('should send Delete key when Del button is pressed', () => {
|
||||
const onSendInput = vi.fn();
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ onSendInput })} />);
|
||||
|
||||
const delButton = screen.getByText('Del');
|
||||
fireEvent.pointerDown(delButton);
|
||||
|
||||
expect(onSendInput).toHaveBeenCalledWith('\x1b[3~');
|
||||
});
|
||||
|
||||
it('should send Home key when Home button is pressed', () => {
|
||||
const onSendInput = vi.fn();
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ onSendInput })} />);
|
||||
|
||||
const homeButton = screen.getByText('Home');
|
||||
fireEvent.pointerDown(homeButton);
|
||||
|
||||
expect(onSendInput).toHaveBeenCalledWith('\x1b[H');
|
||||
});
|
||||
|
||||
it('should send End key when End button is pressed', () => {
|
||||
const onSendInput = vi.fn();
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ onSendInput })} />);
|
||||
|
||||
const endButton = screen.getByText('End');
|
||||
fireEvent.pointerDown(endButton);
|
||||
|
||||
expect(onSendInput).toHaveBeenCalledWith('\x1b[F');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ctrl Key Shortcuts', () => {
|
||||
it('should send Ctrl+C when ^C button is pressed', () => {
|
||||
const onSendInput = vi.fn();
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ onSendInput })} />);
|
||||
|
||||
const ctrlCButton = screen.getByText('^C');
|
||||
fireEvent.pointerDown(ctrlCButton);
|
||||
|
||||
expect(onSendInput).toHaveBeenCalledWith('\x03');
|
||||
});
|
||||
|
||||
it('should send Ctrl+Z when ^Z button is pressed', () => {
|
||||
const onSendInput = vi.fn();
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ onSendInput })} />);
|
||||
|
||||
const ctrlZButton = screen.getByText('^Z');
|
||||
fireEvent.pointerDown(ctrlZButton);
|
||||
|
||||
expect(onSendInput).toHaveBeenCalledWith('\x1a');
|
||||
});
|
||||
|
||||
it('should send Ctrl+B when ^B button is pressed', () => {
|
||||
const onSendInput = vi.fn();
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ onSendInput })} />);
|
||||
|
||||
const ctrlBButton = screen.getByText('^B');
|
||||
fireEvent.pointerDown(ctrlBButton);
|
||||
|
||||
expect(onSendInput).toHaveBeenCalledWith('\x02');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Arrow Keys', () => {
|
||||
it('should send arrow up key when pressed', () => {
|
||||
const onSendInput = vi.fn();
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ onSendInput })} />);
|
||||
|
||||
const upButton = getArrowButton('up');
|
||||
expect(upButton).not.toBeNull();
|
||||
fireEvent.pointerDown(upButton!);
|
||||
|
||||
expect(onSendInput).toHaveBeenCalledWith('\x1b[A');
|
||||
});
|
||||
|
||||
it('should send arrow down key when pressed', () => {
|
||||
const onSendInput = vi.fn();
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ onSendInput })} />);
|
||||
|
||||
const downButton = getArrowButton('down');
|
||||
expect(downButton).not.toBeNull();
|
||||
fireEvent.pointerDown(downButton!);
|
||||
|
||||
expect(onSendInput).toHaveBeenCalledWith('\x1b[B');
|
||||
});
|
||||
|
||||
it('should send arrow right key when pressed', () => {
|
||||
const onSendInput = vi.fn();
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ onSendInput })} />);
|
||||
|
||||
const rightButton = getArrowButton('right');
|
||||
expect(rightButton).not.toBeNull();
|
||||
fireEvent.pointerDown(rightButton!);
|
||||
|
||||
expect(onSendInput).toHaveBeenCalledWith('\x1b[C');
|
||||
});
|
||||
|
||||
it('should send arrow left key when pressed', () => {
|
||||
const onSendInput = vi.fn();
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ onSendInput })} />);
|
||||
|
||||
const leftButton = getArrowButton('left');
|
||||
expect(leftButton).not.toBeNull();
|
||||
fireEvent.pointerDown(leftButton!);
|
||||
|
||||
expect(onSendInput).toHaveBeenCalledWith('\x1b[D');
|
||||
});
|
||||
|
||||
it('should send initial arrow key immediately on press', () => {
|
||||
const onSendInput = vi.fn();
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ onSendInput })} />);
|
||||
|
||||
const upButton = getArrowButton('up');
|
||||
expect(upButton).not.toBeNull();
|
||||
fireEvent.pointerDown(upButton!);
|
||||
|
||||
// Initial press should send immediately
|
||||
expect(onSendInput).toHaveBeenCalledTimes(1);
|
||||
expect(onSendInput).toHaveBeenCalledWith('\x1b[A');
|
||||
|
||||
// Release the button - should not send more
|
||||
fireEvent.pointerUp(upButton!);
|
||||
expect(onSendInput).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should stop repeating when pointer leaves button', () => {
|
||||
const onSendInput = vi.fn();
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ onSendInput })} />);
|
||||
|
||||
const upButton = getArrowButton('up');
|
||||
expect(upButton).not.toBeNull();
|
||||
|
||||
// Press and release via pointer leave
|
||||
fireEvent.pointerDown(upButton!);
|
||||
expect(onSendInput).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Pointer leaves - should clear repeat timers
|
||||
fireEvent.pointerLeave(upButton!);
|
||||
|
||||
// Only the initial press should have been sent
|
||||
expect(onSendInput).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Clipboard Actions', () => {
|
||||
it('should call onCopy when copy button is pressed', () => {
|
||||
const onCopy = vi.fn();
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ onCopy })} />);
|
||||
|
||||
const copyButton = screen.getByTitle('Copy selection');
|
||||
fireEvent.pointerDown(copyButton);
|
||||
|
||||
expect(onCopy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onPaste when paste button is pressed', () => {
|
||||
const onPaste = vi.fn();
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ onPaste })} />);
|
||||
|
||||
const pasteButton = screen.getByTitle('Paste from clipboard');
|
||||
fireEvent.pointerDown(pasteButton);
|
||||
|
||||
expect(onPaste).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onSelectAll when select all button is pressed', () => {
|
||||
const onSelectAll = vi.fn();
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ onSelectAll })} />);
|
||||
|
||||
const selectAllButton = screen.getByTitle('Select all');
|
||||
fireEvent.pointerDown(selectAllButton);
|
||||
|
||||
expect(onSelectAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onToggleSelectMode when select mode button is pressed', () => {
|
||||
const onToggleSelectMode = vi.fn();
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ onToggleSelectMode })} />);
|
||||
|
||||
const selectModeButton = screen.getByTitle('Select text');
|
||||
fireEvent.pointerDown(selectModeButton);
|
||||
|
||||
expect(onToggleSelectMode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should show active state when in select mode', () => {
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ isSelectMode: true })} />);
|
||||
|
||||
const selectModeButton = screen.getByTitle('Exit select mode');
|
||||
expect(selectModeButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Connection State', () => {
|
||||
it('should disable all buttons when not connected', () => {
|
||||
const onSendInput = vi.fn();
|
||||
render(
|
||||
<MobileTerminalShortcuts {...createDefaultProps({ isConnected: false, onSendInput })} />
|
||||
);
|
||||
|
||||
// All shortcut buttons should not send input when disabled
|
||||
const escButton = screen.getByText('Esc');
|
||||
fireEvent.pointerDown(escButton);
|
||||
|
||||
expect(onSendInput).not.toHaveBeenCalled();
|
||||
|
||||
// Arrow keys should also be disabled
|
||||
const upButton = getArrowButton('up');
|
||||
expect(upButton).not.toBeNull();
|
||||
fireEvent.pointerDown(upButton!);
|
||||
|
||||
expect(onSendInput).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass connected state to StickyModifierKeys', () => {
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ isConnected: false })} />);
|
||||
|
||||
const stickyKeys = screen.getByTestId('sticky-modifier-keys');
|
||||
expect(stickyKeys).toHaveAttribute('data-connected', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sticky Modifier Integration', () => {
|
||||
it('should pass active modifier to StickyModifierKeys', () => {
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ activeModifier: 'ctrl' })} />);
|
||||
|
||||
const stickyKeys = screen.getByTestId('sticky-modifier-keys');
|
||||
expect(stickyKeys).toHaveAttribute('data-modifier', 'ctrl');
|
||||
});
|
||||
|
||||
it('should call onModifierChange when modifier is changed', () => {
|
||||
const onModifierChange = vi.fn();
|
||||
render(<MobileTerminalShortcuts {...createDefaultProps({ onModifierChange })} />);
|
||||
|
||||
const ctrlBtn = screen.getByTestId('ctrl-btn');
|
||||
fireEvent.click(ctrlBtn);
|
||||
|
||||
expect(onModifierChange).toHaveBeenCalledWith('ctrl');
|
||||
});
|
||||
});
|
||||
});
|
||||
411
apps/ui/tests/unit/components/phase-model-selector.test.tsx
Normal file
411
apps/ui/tests/unit/components/phase-model-selector.test.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Unit tests for PhaseModelSelector component
|
||||
* Tests useShallow selector reactivity with enabledDynamicModelIds array changes
|
||||
*
|
||||
* Bug: Opencode model selection changes from settings aren't showing up in dropdown
|
||||
* Fix: Added useShallow selector to ensure proper reactivity when enabledDynamicModelIds array changes
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
|
||||
// Mock the store
|
||||
vi.mock('@/store/app-store');
|
||||
|
||||
const mockUseAppStore = useAppStore as ReturnType<typeof vi.fn>;
|
||||
|
||||
/**
|
||||
* Type definition for the mock store state to ensure type safety across tests
|
||||
*/
|
||||
interface MockStoreState {
|
||||
enabledDynamicModelIds: string[];
|
||||
enabledCursorModels: string[];
|
||||
enabledGeminiModels: string[];
|
||||
enabledCopilotModels: string[];
|
||||
favoriteModels: string[];
|
||||
toggleFavoriteModel: ReturnType<typeof vi.fn>;
|
||||
codexModels: unknown[];
|
||||
codexModelsLoading: boolean;
|
||||
fetchCodexModels: ReturnType<typeof vi.fn>;
|
||||
disabledProviders: string[];
|
||||
claudeCompatibleProviders: string[];
|
||||
defaultThinkingLevel?: string;
|
||||
defaultReasoningEffort?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a mock store state with default values that can be overridden
|
||||
* @param overrides - Partial state object to override defaults
|
||||
* @returns A complete mock store state object
|
||||
*/
|
||||
function createMockStoreState(overrides: Partial<MockStoreState> = {}): MockStoreState {
|
||||
return {
|
||||
enabledDynamicModelIds: [],
|
||||
enabledCursorModels: [],
|
||||
enabledGeminiModels: [],
|
||||
enabledCopilotModels: [],
|
||||
favoriteModels: [],
|
||||
toggleFavoriteModel: vi.fn(),
|
||||
codexModels: [],
|
||||
codexModelsLoading: false,
|
||||
fetchCodexModels: vi.fn().mockResolvedValue([]),
|
||||
disabledProviders: [],
|
||||
claudeCompatibleProviders: [],
|
||||
defaultThinkingLevel: undefined,
|
||||
defaultReasoningEffort: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PhaseModelSelector - useShallow Selector Behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useShallow selector reactivity with enabledDynamicModelIds', () => {
|
||||
it('should properly track selector call counts', () => {
|
||||
// Verify that when useAppStore is called with a selector (useShallow pattern),
|
||||
// it properly extracts the required state values
|
||||
|
||||
let _capturedSelector: ((state: MockStoreState) => Partial<MockStoreState>) | null = null;
|
||||
|
||||
// Mock useAppStore to capture the selector function
|
||||
mockUseAppStore.mockImplementation((selector?: unknown) => {
|
||||
if (typeof selector === 'function') {
|
||||
_capturedSelector = selector as (state: MockStoreState) => Partial<MockStoreState>;
|
||||
}
|
||||
const mockState = createMockStoreState();
|
||||
return typeof selector === 'function' ? selector(mockState) : mockState;
|
||||
});
|
||||
|
||||
// Call useAppStore (simulating what PhaseModelSelector does)
|
||||
const { result } = renderHook(() => useAppStore());
|
||||
|
||||
// Verify we got a result back (meaning the selector was applied)
|
||||
expect(result).toBeDefined();
|
||||
expect(typeof result).toBe('object');
|
||||
|
||||
// Now test that a selector function would extract enabledDynamicModelIds correctly
|
||||
// This simulates the useShallow selector pattern
|
||||
const testState = createMockStoreState({
|
||||
enabledDynamicModelIds: ['model-1', 'model-2'],
|
||||
});
|
||||
|
||||
// Simulate the selector function that useShallow wraps
|
||||
const simulatedSelector = (state: MockStoreState) => ({
|
||||
enabledDynamicModelIds: state.enabledDynamicModelIds,
|
||||
enabledCursorModels: state.enabledCursorModels,
|
||||
enabledGeminiModels: state.enabledGeminiModels,
|
||||
enabledCopilotModels: state.enabledCopilotModels,
|
||||
});
|
||||
|
||||
const selectorResult = simulatedSelector(testState);
|
||||
expect(selectorResult).toHaveProperty('enabledDynamicModelIds');
|
||||
expect(selectorResult.enabledDynamicModelIds).toEqual(['model-1', 'model-2']);
|
||||
});
|
||||
|
||||
it('should detect changes when enabledDynamicModelIds array reference changes', () => {
|
||||
// Test that useShallow properly handles array reference changes
|
||||
// This simulates what happens when toggleDynamicModel is called
|
||||
|
||||
const results: Partial<MockStoreState>[] = [];
|
||||
|
||||
mockUseAppStore.mockImplementation((selector?: unknown) => {
|
||||
const mockState = createMockStoreState({
|
||||
enabledDynamicModelIds: ['model-1'],
|
||||
});
|
||||
|
||||
const result = typeof selector === 'function' ? selector(mockState) : mockState;
|
||||
results.push(result);
|
||||
return result;
|
||||
});
|
||||
|
||||
// First call
|
||||
renderHook(() => useAppStore());
|
||||
const firstCallResult = results[0];
|
||||
expect(firstCallResult?.enabledDynamicModelIds).toEqual(['model-1']);
|
||||
|
||||
// Simulate store update with new array reference
|
||||
mockUseAppStore.mockImplementation((selector?: unknown) => {
|
||||
const mockState = createMockStoreState({
|
||||
enabledDynamicModelIds: ['model-1', 'model-2'], // New array reference
|
||||
});
|
||||
|
||||
const result = typeof selector === 'function' ? selector(mockState) : mockState;
|
||||
results.push(result);
|
||||
return result;
|
||||
});
|
||||
|
||||
// Second call with updated state
|
||||
renderHook(() => useAppStore());
|
||||
const secondCallResult = results[1];
|
||||
expect(secondCallResult?.enabledDynamicModelIds).toEqual(['model-1', 'model-2']);
|
||||
|
||||
// Verify that the arrays have different references (useShallow handles this)
|
||||
expect(firstCallResult?.enabledDynamicModelIds).not.toBe(
|
||||
secondCallResult?.enabledDynamicModelIds
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Store state integration with enabledDynamicModelIds', () => {
|
||||
it('should return all required state values from the selector', () => {
|
||||
mockUseAppStore.mockImplementation((selector?: unknown) => {
|
||||
const mockState = createMockStoreState({
|
||||
enabledCursorModels: ['cursor-small'],
|
||||
enabledGeminiModels: ['gemini-flash'],
|
||||
enabledCopilotModels: ['gpt-4o'],
|
||||
enabledDynamicModelIds: ['custom-model-1'],
|
||||
defaultThinkingLevel: 'medium',
|
||||
defaultReasoningEffort: 'medium',
|
||||
});
|
||||
|
||||
return typeof selector === 'function' ? selector(mockState) : mockState;
|
||||
});
|
||||
|
||||
const result = renderHook(() => useAppStore()).result.current;
|
||||
|
||||
// Verify all required properties are present
|
||||
expect(result).toHaveProperty('enabledCursorModels');
|
||||
expect(result).toHaveProperty('enabledGeminiModels');
|
||||
expect(result).toHaveProperty('enabledCopilotModels');
|
||||
expect(result).toHaveProperty('favoriteModels');
|
||||
expect(result).toHaveProperty('toggleFavoriteModel');
|
||||
expect(result).toHaveProperty('codexModels');
|
||||
expect(result).toHaveProperty('codexModelsLoading');
|
||||
expect(result).toHaveProperty('fetchCodexModels');
|
||||
expect(result).toHaveProperty('enabledDynamicModelIds');
|
||||
expect(result).toHaveProperty('disabledProviders');
|
||||
expect(result).toHaveProperty('claudeCompatibleProviders');
|
||||
expect(result).toHaveProperty('defaultThinkingLevel');
|
||||
expect(result).toHaveProperty('defaultReasoningEffort');
|
||||
|
||||
// Verify values
|
||||
expect(result.enabledCursorModels).toEqual(['cursor-small']);
|
||||
expect(result.enabledGeminiModels).toEqual(['gemini-flash']);
|
||||
expect(result.enabledCopilotModels).toEqual(['gpt-4o']);
|
||||
expect(result.enabledDynamicModelIds).toEqual(['custom-model-1']);
|
||||
});
|
||||
|
||||
it('should handle empty enabledDynamicModelIds array', () => {
|
||||
mockUseAppStore.mockImplementation((selector?: unknown) => {
|
||||
const mockState = createMockStoreState({
|
||||
enabledDynamicModelIds: [],
|
||||
});
|
||||
|
||||
return typeof selector === 'function' ? selector(mockState) : mockState;
|
||||
});
|
||||
|
||||
const result = renderHook(() => useAppStore()).result.current;
|
||||
expect(result.enabledDynamicModelIds).toEqual([]);
|
||||
expect(Array.isArray(result.enabledDynamicModelIds)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Array reference changes with useShallow', () => {
|
||||
it('should detect changes when array content changes', () => {
|
||||
const referenceComparisons: { array: string[]; isArray: boolean; length: number }[] = [];
|
||||
|
||||
mockUseAppStore.mockImplementation((selector?: unknown) => {
|
||||
const mockState = createMockStoreState({
|
||||
enabledDynamicModelIds: ['model-1', 'model-2'],
|
||||
});
|
||||
|
||||
const result = typeof selector === 'function' ? selector(mockState) : mockState;
|
||||
referenceComparisons.push({
|
||||
array: result.enabledDynamicModelIds,
|
||||
isArray: Array.isArray(result.enabledDynamicModelIds),
|
||||
length: result.enabledDynamicModelIds.length,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
// First call
|
||||
renderHook(() => useAppStore());
|
||||
|
||||
// Update to new array with different length
|
||||
mockUseAppStore.mockImplementation((selector?: unknown) => {
|
||||
const mockState = createMockStoreState({
|
||||
enabledDynamicModelIds: ['model-1', 'model-2', 'model-3'], // New array with additional item
|
||||
});
|
||||
|
||||
const result = typeof selector === 'function' ? selector(mockState) : mockState;
|
||||
referenceComparisons.push({
|
||||
array: result.enabledDynamicModelIds,
|
||||
isArray: Array.isArray(result.enabledDynamicModelIds),
|
||||
length: result.enabledDynamicModelIds.length,
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
// Second call
|
||||
renderHook(() => useAppStore());
|
||||
|
||||
// Verify both calls produced arrays
|
||||
expect(referenceComparisons[0].isArray).toBe(true);
|
||||
expect(referenceComparisons[1].isArray).toBe(true);
|
||||
|
||||
// Verify the length changed (new array reference)
|
||||
expect(referenceComparisons[0].length).toBe(2);
|
||||
expect(referenceComparisons[1].length).toBe(3);
|
||||
|
||||
// Verify different array references
|
||||
expect(referenceComparisons[0].array).not.toBe(referenceComparisons[1].array);
|
||||
});
|
||||
|
||||
it('should handle array removal correctly', () => {
|
||||
const snapshots: string[][] = [];
|
||||
|
||||
mockUseAppStore.mockImplementation((selector?: unknown) => {
|
||||
const mockState = createMockStoreState({
|
||||
enabledDynamicModelIds: ['model-1', 'model-2', 'model-3'],
|
||||
});
|
||||
|
||||
const result = typeof selector === 'function' ? selector(mockState) : mockState;
|
||||
snapshots.push([...result.enabledDynamicModelIds]);
|
||||
return result;
|
||||
});
|
||||
|
||||
// Initial state with 3 models
|
||||
renderHook(() => useAppStore());
|
||||
expect(snapshots[0]).toEqual(['model-1', 'model-2', 'model-3']);
|
||||
|
||||
// Remove one model (simulate user toggling off)
|
||||
mockUseAppStore.mockImplementation((selector?: unknown) => {
|
||||
const mockState = createMockStoreState({
|
||||
enabledDynamicModelIds: ['model-1', 'model-3'], // model-2 removed
|
||||
});
|
||||
|
||||
const result = typeof selector === 'function' ? selector(mockState) : mockState;
|
||||
snapshots.push([...result.enabledDynamicModelIds]);
|
||||
return result;
|
||||
});
|
||||
|
||||
// Updated state
|
||||
renderHook(() => useAppStore());
|
||||
expect(snapshots[1]).toEqual(['model-1', 'model-3']);
|
||||
|
||||
// Verify different array references
|
||||
expect(snapshots[0]).not.toBe(snapshots[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Code contract verification', () => {
|
||||
it('should verify useShallow import is present', () => {
|
||||
// Read the component file and verify useShallow is imported
|
||||
const componentPath = path.resolve(
|
||||
__dirname,
|
||||
'../../../src/components/views/settings-view/model-defaults/phase-model-selector.tsx'
|
||||
);
|
||||
const componentCode = fs.readFileSync(componentPath, 'utf-8');
|
||||
|
||||
// Verify the fix is in place
|
||||
expect(componentCode).toMatch(/import.*useShallow.*from.*zustand\/react\/shallow/);
|
||||
});
|
||||
|
||||
it('should verify useAppStore call uses useShallow', () => {
|
||||
const componentPath = path.resolve(
|
||||
__dirname,
|
||||
'../../../src/components/views/settings-view/model-defaults/phase-model-selector.tsx'
|
||||
);
|
||||
const componentCode = fs.readFileSync(componentPath, 'utf-8');
|
||||
|
||||
// Look for the useAppStore pattern with useShallow
|
||||
// The pattern should be: useAppStore(useShallow((state) => ({ ... })))
|
||||
expect(componentCode).toMatch(/useAppStore\(\s*useShallow\(/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PhaseModelSelector - enabledDynamicModelIds filtering logic', () => {
|
||||
describe('Array filtering behavior', () => {
|
||||
it('should filter dynamic models based on enabledDynamicModelIds', () => {
|
||||
// This test verifies the filtering logic concept
|
||||
// The actual filtering happens in the useMemo within PhaseModelSelector
|
||||
|
||||
const dynamicOpencodeModels = [
|
||||
{
|
||||
id: 'custom-model-1',
|
||||
name: 'Custom Model 1',
|
||||
description: 'First',
|
||||
tier: 'basic',
|
||||
maxTokens: 200000,
|
||||
},
|
||||
{
|
||||
id: 'custom-model-2',
|
||||
name: 'Custom Model 2',
|
||||
description: 'Second',
|
||||
tier: 'premium',
|
||||
maxTokens: 200000,
|
||||
},
|
||||
{
|
||||
id: 'custom-model-3',
|
||||
name: 'Custom Model 3',
|
||||
description: 'Third',
|
||||
tier: 'basic',
|
||||
maxTokens: 200000,
|
||||
},
|
||||
];
|
||||
|
||||
const enabledDynamicModelIds = ['custom-model-1', 'custom-model-3'];
|
||||
|
||||
// Simulate the filter logic from the component
|
||||
const filteredModels = dynamicOpencodeModels.filter((model) =>
|
||||
enabledDynamicModelIds.includes(model.id)
|
||||
);
|
||||
|
||||
expect(filteredModels).toHaveLength(2);
|
||||
expect(filteredModels.map((m) => m.id)).toEqual(['custom-model-1', 'custom-model-3']);
|
||||
});
|
||||
|
||||
it('should return empty array when no dynamic models are enabled', () => {
|
||||
const dynamicOpencodeModels = [
|
||||
{
|
||||
id: 'custom-model-1',
|
||||
name: 'Custom Model 1',
|
||||
description: 'First',
|
||||
tier: 'basic',
|
||||
maxTokens: 200000,
|
||||
},
|
||||
];
|
||||
|
||||
const enabledDynamicModelIds: string[] = [];
|
||||
|
||||
const filteredModels = dynamicOpencodeModels.filter((model) =>
|
||||
enabledDynamicModelIds.includes(model.id)
|
||||
);
|
||||
|
||||
expect(filteredModels).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return all models when all are enabled', () => {
|
||||
const dynamicOpencodeModels = [
|
||||
{
|
||||
id: 'custom-model-1',
|
||||
name: 'Custom Model 1',
|
||||
description: 'First',
|
||||
tier: 'basic',
|
||||
maxTokens: 200000,
|
||||
},
|
||||
{
|
||||
id: 'custom-model-2',
|
||||
name: 'Custom Model 2',
|
||||
description: 'Second',
|
||||
tier: 'premium',
|
||||
maxTokens: 200000,
|
||||
},
|
||||
];
|
||||
|
||||
const enabledDynamicModelIds = ['custom-model-1', 'custom-model-2'];
|
||||
|
||||
const filteredModels = dynamicOpencodeModels.filter((model) =>
|
||||
enabledDynamicModelIds.includes(model.id)
|
||||
);
|
||||
|
||||
expect(filteredModels).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Tests for PRCommentResolutionPRInfo interface and URL passthrough
|
||||
*
|
||||
* Verifies that the PRCommentResolutionPRInfo type properly carries the URL
|
||||
* from the board-view worktree panel through to the PR comment resolution dialog,
|
||||
* enabling prUrl to be set on created features.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { PRCommentResolutionPRInfo } from '../../../src/components/dialogs/pr-comment-resolution-dialog';
|
||||
|
||||
describe('PRCommentResolutionPRInfo interface', () => {
|
||||
it('should accept PR info with url field', () => {
|
||||
const prInfo: PRCommentResolutionPRInfo = {
|
||||
number: 42,
|
||||
title: 'Fix auth flow',
|
||||
url: 'https://github.com/org/repo/pull/42',
|
||||
};
|
||||
|
||||
expect(prInfo.url).toBe('https://github.com/org/repo/pull/42');
|
||||
});
|
||||
|
||||
it('should accept PR info without url field (optional)', () => {
|
||||
const prInfo: PRCommentResolutionPRInfo = {
|
||||
number: 42,
|
||||
title: 'Fix auth flow',
|
||||
};
|
||||
|
||||
expect(prInfo.url).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should accept PR info with headRefName', () => {
|
||||
const prInfo: PRCommentResolutionPRInfo = {
|
||||
number: 42,
|
||||
title: 'Fix auth flow',
|
||||
headRefName: 'feature/auth-fix',
|
||||
url: 'https://github.com/org/repo/pull/42',
|
||||
};
|
||||
|
||||
expect(prInfo.headRefName).toBe('feature/auth-fix');
|
||||
expect(prInfo.url).toBe('https://github.com/org/repo/pull/42');
|
||||
});
|
||||
|
||||
it('should correctly represent board-view to dialog passthrough', () => {
|
||||
// Simulates what handleAddressPRComments does in board-view.tsx
|
||||
const worktree = { branch: 'fix/my-fix' };
|
||||
const prInfo = {
|
||||
number: 123,
|
||||
title: 'My PR',
|
||||
url: 'https://github.com/org/repo/pull/123',
|
||||
};
|
||||
|
||||
const dialogPRInfo: PRCommentResolutionPRInfo = {
|
||||
number: prInfo.number,
|
||||
title: prInfo.title,
|
||||
headRefName: worktree.branch,
|
||||
url: prInfo.url,
|
||||
};
|
||||
|
||||
expect(dialogPRInfo.number).toBe(123);
|
||||
expect(dialogPRInfo.title).toBe('My PR');
|
||||
expect(dialogPRInfo.headRefName).toBe('fix/my-fix');
|
||||
expect(dialogPRInfo.url).toBe('https://github.com/org/repo/pull/123');
|
||||
});
|
||||
|
||||
it('should handle board-view passthrough when PR has no URL', () => {
|
||||
const worktree = { branch: 'fix/my-fix' };
|
||||
const prInfo = { number: 123, title: 'My PR' };
|
||||
|
||||
const dialogPRInfo: PRCommentResolutionPRInfo = {
|
||||
number: prInfo.number,
|
||||
title: prInfo.title,
|
||||
headRefName: worktree.branch,
|
||||
};
|
||||
|
||||
expect(dialogPRInfo.url).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should spread prUrl conditionally based on url presence', () => {
|
||||
// This tests the pattern: ...(pr.url ? { prUrl: pr.url } : {})
|
||||
const prWithUrl: PRCommentResolutionPRInfo = {
|
||||
number: 1,
|
||||
title: 'Test',
|
||||
url: 'https://github.com/test',
|
||||
};
|
||||
const prWithoutUrl: PRCommentResolutionPRInfo = {
|
||||
number: 2,
|
||||
title: 'Test',
|
||||
};
|
||||
|
||||
const featureWithUrl = {
|
||||
id: 'test',
|
||||
...(prWithUrl.url ? { prUrl: prWithUrl.url } : {}),
|
||||
};
|
||||
const featureWithoutUrl = {
|
||||
id: 'test',
|
||||
...(prWithoutUrl.url ? { prUrl: prWithoutUrl.url } : {}),
|
||||
};
|
||||
|
||||
expect(featureWithUrl).toHaveProperty('prUrl', 'https://github.com/test');
|
||||
expect(featureWithoutUrl).not.toHaveProperty('prUrl');
|
||||
});
|
||||
});
|
||||
192
apps/ui/tests/unit/components/worktree-panel-props.test.ts
Normal file
192
apps/ui/tests/unit/components/worktree-panel-props.test.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Tests to validate worktree-panel.tsx prop integrity after rebase conflict resolution.
|
||||
*
|
||||
* During the rebase onto upstream/v1.0.0rc, duplicate JSX props (isDevServerStarting,
|
||||
* isStartingAnyDevServer) were introduced by overlapping commits. This test validates
|
||||
* that the source code has no duplicate JSX prop assignments, which would cause
|
||||
* React warnings and unpredictable behavior (last value wins).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('worktree-panel.tsx prop integrity', () => {
|
||||
const filePath = path.resolve(
|
||||
__dirname,
|
||||
'../../../src/components/views/board-view/worktree-panel/worktree-panel.tsx'
|
||||
);
|
||||
|
||||
let sourceCode: string;
|
||||
|
||||
beforeAll(() => {
|
||||
sourceCode = fs.readFileSync(filePath, 'utf-8');
|
||||
});
|
||||
|
||||
it('should not have duplicate isDevServerStarting props within any single JSX element', () => {
|
||||
// Parse JSX elements and verify no element has isDevServerStarting more than once.
|
||||
// Props are passed to WorktreeTab, WorktreeMobileDropdown, WorktreeActionsDropdown, etc.
|
||||
// Each individual element should have the prop at most once.
|
||||
const lines = sourceCode.split('\n');
|
||||
let inElement = false;
|
||||
let propCount = 0;
|
||||
let elementName = '';
|
||||
const violations: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const trimmed = lines[i].trimStart();
|
||||
|
||||
const elementStart = trimmed.match(/^<(\w+)\b/);
|
||||
if (elementStart && !trimmed.startsWith('</')) {
|
||||
inElement = true;
|
||||
propCount = 0;
|
||||
elementName = elementStart[1];
|
||||
}
|
||||
|
||||
if (inElement && trimmed.includes('isDevServerStarting=')) {
|
||||
propCount++;
|
||||
if (propCount > 1) {
|
||||
violations.push(`Duplicate isDevServerStarting in <${elementName}> at line ${i + 1}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
inElement &&
|
||||
(trimmed.includes('/>') || (trimmed.endsWith('>') && !trimmed.includes('=')))
|
||||
) {
|
||||
inElement = false;
|
||||
}
|
||||
}
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
// Verify the prop is actually used somewhere
|
||||
expect(sourceCode).toContain('isDevServerStarting=');
|
||||
});
|
||||
|
||||
it('should not have duplicate isStartingAnyDevServer props within any single JSX element', () => {
|
||||
const lines = sourceCode.split('\n');
|
||||
let inElement = false;
|
||||
let propCount = 0;
|
||||
let elementName = '';
|
||||
const violations: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const trimmed = lines[i].trimStart();
|
||||
|
||||
const elementStart = trimmed.match(/^<(\w+)\b/);
|
||||
if (elementStart && !trimmed.startsWith('</')) {
|
||||
inElement = true;
|
||||
propCount = 0;
|
||||
elementName = elementStart[1];
|
||||
}
|
||||
|
||||
if (inElement && trimmed.includes('isStartingAnyDevServer=')) {
|
||||
propCount++;
|
||||
if (propCount > 1) {
|
||||
violations.push(`Duplicate isStartingAnyDevServer in <${elementName}> at line ${i + 1}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
inElement &&
|
||||
(trimmed.includes('/>') || (trimmed.endsWith('>') && !trimmed.includes('=')))
|
||||
) {
|
||||
inElement = false;
|
||||
}
|
||||
}
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not have any JSX element with duplicate prop names', () => {
|
||||
// Parse all JSX-like blocks and check for duplicate props
|
||||
// This regex finds prop assignments like propName={...} or propName="..."
|
||||
const lines = sourceCode.split('\n');
|
||||
|
||||
// Track props per JSX element by looking for indentation patterns
|
||||
// A JSX opening tag starts with < and ends when indentation drops
|
||||
let currentJsxProps: Map<string, number[]> = new Map();
|
||||
let inJsxElement = false;
|
||||
let _elementIndent = 0;
|
||||
|
||||
const duplicates: Array<{ prop: string; line: number; element: string }> = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trimStart();
|
||||
const indent = line.length - trimmed.length;
|
||||
|
||||
// Detect start of JSX element
|
||||
if (trimmed.startsWith('<') && !trimmed.startsWith('</') && !trimmed.startsWith('{')) {
|
||||
const elementMatch = trimmed.match(/^<(\w+)/);
|
||||
if (elementMatch) {
|
||||
inJsxElement = true;
|
||||
_elementIndent = indent;
|
||||
currentJsxProps = new Map();
|
||||
}
|
||||
}
|
||||
|
||||
if (inJsxElement) {
|
||||
// Extract prop names from this line (prop={value} or prop="value")
|
||||
const propMatches = trimmed.matchAll(/\b(\w+)=\{/g);
|
||||
for (const match of propMatches) {
|
||||
const propName = match[1];
|
||||
if (!currentJsxProps.has(propName)) {
|
||||
currentJsxProps.set(propName, []);
|
||||
}
|
||||
currentJsxProps.get(propName)!.push(i + 1);
|
||||
|
||||
// Check for duplicates
|
||||
if (currentJsxProps.get(propName)!.length > 1) {
|
||||
duplicates.push({
|
||||
prop: propName,
|
||||
line: i + 1,
|
||||
element: trimmed.substring(0, 50),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Detect end of JSX element (self-closing /> or >)
|
||||
if (trimmed.includes('/>') || (trimmed.endsWith('>') && !trimmed.includes('='))) {
|
||||
inJsxElement = false;
|
||||
currentJsxProps = new Map();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(duplicates).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('worktree-panel.tsx uses both isStartingAnyDevServer and isDevServerStarting', () => {
|
||||
const filePath = path.resolve(
|
||||
__dirname,
|
||||
'../../../src/components/views/board-view/worktree-panel/worktree-panel.tsx'
|
||||
);
|
||||
|
||||
let sourceCode: string;
|
||||
|
||||
beforeAll(() => {
|
||||
sourceCode = fs.readFileSync(filePath, 'utf-8');
|
||||
});
|
||||
|
||||
it('should use isStartingAnyDevServer from the useDevServers hook', () => {
|
||||
// The hook destructuring should include isStartingAnyDevServer
|
||||
expect(sourceCode).toContain('isStartingAnyDevServer');
|
||||
});
|
||||
|
||||
it('should use isDevServerStarting from the useDevServers hook', () => {
|
||||
// The hook destructuring should include isDevServerStarting
|
||||
expect(sourceCode).toContain('isDevServerStarting');
|
||||
});
|
||||
|
||||
it('isStartingAnyDevServer and isDevServerStarting should be distinct concepts', () => {
|
||||
// isStartingAnyDevServer is a boolean (any server starting)
|
||||
// isDevServerStarting is a function (specific worktree starting)
|
||||
// Both should be destructured from the hook
|
||||
const hookDestructuring = sourceCode.match(
|
||||
/const\s*\{[^}]*isStartingAnyDevServer[^}]*isDevServerStarting[^}]*\}/s
|
||||
);
|
||||
expect(hookDestructuring).not.toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user