Add orphaned features management routes and UI integration (#819)

* 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

* refactor(auto-mode): enhance orphaned feature detection and improve project initialization

- Updated detectOrphanedFeatures method to accept preloaded features, reducing redundant disk reads.
- Improved project initialization by creating required directories and files in parallel for better performance.
- Adjusted planning mode handling in UI components to clarify approval requirements for different modes.
- Added refresh functionality for file editor tabs to ensure content consistency with disk state.

These changes enhance performance, maintainability, and user experience across the application.

* feat(orphaned-features): add orphaned features management routes and UI integration

- Introduced new routes for managing orphaned features, including listing, resolving, and bulk resolving.
- Updated the UI to include an Orphaned Features section in project settings and navigation.
- Enhanced the execution service to support new orphaned feature functionalities.

These changes improve the application's capability to handle orphaned features effectively, enhancing user experience and project management.

* fix: Normalize line endings and resolve stale dirty states in file editor

* chore: Update .gitignore and enhance orphaned feature handling

- Added a blank line in .gitignore for better readability.
- Introduced a hash to worktree paths in orphaned feature resolution to prevent conflicts.
- Added validation for target branch existence during orphaned feature resolution.
- Improved prompt formatting in execution service for clarity.
- Enhanced error handling in project selector for project initialization failures.
- Refactored orphaned features section to improve state management and UI responsiveness.

These changes improve code maintainability and user experience when managing orphaned features.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
gsxdsm
2026-02-27 22:14:41 -08:00
committed by GitHub
parent 0196911d59
commit 1c0e460dd1
36 changed files with 2048 additions and 406 deletions

View File

@@ -0,0 +1,352 @@
/**
* E2E tests for AgentOutputModal responsive behavior
* These tests verify the modal width changes across different screen sizes
*/
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import {
createTempDirPath,
cleanupTempDir,
setupRealProject,
waitForNetworkIdle,
authenticateForTests,
handleLoginScreenIfPresent,
} from '../../utils';
const TEST_TEMP_DIR = createTempDirPath('responsive-modal-test');
/**
* Create a verified feature with agent output on disk so the Logs button appears
*/
function createVerifiedFeature(projectPath: string, featureId: string, description: string): void {
const featureDir = path.join(projectPath, '.automaker', 'features', featureId);
fs.mkdirSync(featureDir, { recursive: true });
fs.writeFileSync(
path.join(featureDir, 'agent-output.md'),
`## Summary\nFeature implemented successfully.\n\n## Details\n${description}`,
{ encoding: 'utf-8' }
);
fs.writeFileSync(
path.join(featureDir, 'feature.json'),
JSON.stringify(
{
id: featureId,
title: description,
category: 'default',
description,
status: 'verified',
},
null,
2
),
{ encoding: 'utf-8' }
);
}
test.describe('AgentOutputModal Responsive Behavior', () => {
let projectPath: string;
const projectName = `test-responsive-${Date.now()}`;
test.beforeAll(async () => {
if (!fs.existsSync(TEST_TEMP_DIR)) {
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
}
projectPath = path.join(TEST_TEMP_DIR, projectName);
fs.mkdirSync(projectPath, { recursive: true });
fs.writeFileSync(
path.join(projectPath, 'package.json'),
JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2)
);
const automakerDir = path.join(projectPath, '.automaker');
fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true });
fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true });
fs.writeFileSync(
path.join(automakerDir, 'categories.json'),
JSON.stringify({ categories: [] }, null, 2)
);
fs.writeFileSync(
path.join(automakerDir, 'app_spec.txt'),
`# ${projectName}\n\nA test project for responsive modal testing.`
);
});
test.afterAll(async () => {
cleanupTempDir(TEST_TEMP_DIR);
});
/**
* Helper: set up project, create a verified feature on disk, navigate to board,
* and open the agent output modal via the Logs button.
*/
async function setupAndOpenModal(page: import('@playwright/test').Page): Promise<string> {
const featureId = `responsive-feat-${Date.now()}`;
createVerifiedFeature(projectPath, featureId, 'Responsive test feature');
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
await authenticateForTests(page);
await page.goto('/board');
await page.waitForLoadState('load');
await handleLoginScreenIfPresent(page);
await waitForNetworkIdle(page);
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
// Wait for the verified feature card to appear
const featureCard = page.locator(`[data-testid="kanban-card-${featureId}"]`);
await expect(featureCard).toBeVisible({ timeout: 10000 });
// Click the Logs button on the verified feature card to open the output modal
const logsButton = page.locator(`[data-testid="view-output-verified-${featureId}"]`);
await expect(logsButton).toBeVisible({ timeout: 5000 });
await logsButton.click();
// Wait for modal
await expect(page.locator('[data-testid="agent-output-modal"]')).toBeVisible({
timeout: 10000,
});
return featureId;
}
test.describe('Mobile View (< 640px)', () => {
test('should use full width on mobile screens', async ({ page }) => {
await setupAndOpenModal(page);
await page.setViewportSize({ width: 375, height: 667 });
await page.waitForTimeout(200);
const modal = page.locator('[data-testid="agent-output-modal"]');
const modalWidth = await modal.evaluate((el) => el.offsetWidth);
const viewportWidth = await page.evaluate(() => window.innerWidth);
// Modal should be close to full width (within 2rem = 32px margins)
expect(modalWidth).toBeGreaterThan(viewportWidth - 40);
});
test('should have proper max width constraint on mobile', async ({ page }) => {
await setupAndOpenModal(page);
await page.setViewportSize({ width: 320, height: 568 });
await page.waitForTimeout(200);
const modal = page.locator('[data-testid="agent-output-modal"]');
const classList = await modal.evaluate((el) => el.className);
expect(classList).toContain('max-w-[calc(100%-2rem)]');
});
});
test.describe('Small View (640px - 768px)', () => {
test('should use 60vw on small screens', async ({ page }) => {
await setupAndOpenModal(page);
await page.setViewportSize({ width: 640, height: 768 });
await page.waitForTimeout(200);
const modal = page.locator('[data-testid="agent-output-modal"]');
const modalWidth = await modal.evaluate((el) => el.offsetWidth);
const viewportWidth = await page.evaluate(() => window.innerWidth);
// At 640px (sm breakpoint), width should be ~60vw = 384px
const expected60vw = viewportWidth * 0.6;
expect(modalWidth).toBeLessThanOrEqual(expected60vw + 5);
expect(modalWidth).toBeGreaterThanOrEqual(expected60vw - 5);
});
test('should have 80vh max height on small screens', async ({ page }) => {
await setupAndOpenModal(page);
await page.setViewportSize({ width: 640, height: 768 });
await page.waitForTimeout(200);
const modal = page.locator('[data-testid="agent-output-modal"]');
const classList = await modal.evaluate((el) => el.className);
expect(classList).toContain('sm:max-h-[80vh]');
});
});
test.describe('Tablet View (>= 768px)', () => {
test('should use 90vw on tablet screens', async ({ page }) => {
await setupAndOpenModal(page);
await page.setViewportSize({ width: 768, height: 1024 });
await page.waitForTimeout(200);
const modal = page.locator('[data-testid="agent-output-modal"]');
const modalWidth = await modal.evaluate((el) => el.offsetWidth);
const viewportWidth = await page.evaluate(() => window.innerWidth);
// At 768px (md breakpoint), width should be ~90vw = ~691px
const expected90vw = viewportWidth * 0.9;
expect(modalWidth).toBeLessThanOrEqual(expected90vw + 5);
expect(modalWidth).toBeGreaterThanOrEqual(expected90vw - 5);
});
test('should have 1200px max width on tablet', async ({ page }) => {
await setupAndOpenModal(page);
await page.setViewportSize({ width: 768, height: 1024 });
await page.waitForTimeout(200);
const modal = page.locator('[data-testid="agent-output-modal"]');
const classList = await modal.evaluate((el) => el.className);
expect(classList).toContain('md:max-w-[1200px]');
});
test('should have 85vh max height on tablet screens', async ({ page }) => {
await setupAndOpenModal(page);
await page.setViewportSize({ width: 768, height: 1024 });
await page.waitForTimeout(200);
const modal = page.locator('[data-testid="agent-output-modal"]');
const classList = await modal.evaluate((el) => el.className);
expect(classList).toContain('md:max-h-[85vh]');
});
test('should maintain correct height on larger tablets', async ({ page }) => {
await setupAndOpenModal(page);
await page.setViewportSize({ width: 1024, height: 1366 });
await page.waitForTimeout(200);
const modal = page.locator('[data-testid="agent-output-modal"]');
const modalHeight = await modal.evaluate((el) => el.offsetHeight);
const viewportHeight = await page.evaluate(() => window.innerHeight);
// Height should be <= 85vh
const expected85vh = viewportHeight * 0.85;
expect(modalHeight).toBeLessThanOrEqual(expected85vh + 5);
});
});
test.describe('Responsive Transitions', () => {
test('should update modal size when resizing from mobile to tablet', async ({ page }) => {
await setupAndOpenModal(page);
// Start with mobile size
await page.setViewportSize({ width: 375, height: 667 });
await page.waitForTimeout(200);
const modal = page.locator('[data-testid="agent-output-modal"]');
const mobileWidth = await modal.evaluate((el) => el.offsetWidth);
const mobileViewport = 375;
// Mobile: close to full width
expect(mobileWidth).toBeGreaterThan(mobileViewport - 40);
// Resize to tablet
await page.setViewportSize({ width: 768, height: 1024 });
await page.waitForTimeout(200);
const tabletWidth = await modal.evaluate((el) => el.offsetWidth);
const tabletViewport = 768;
// Tablet: should be ~90vw
const expected90vw = tabletViewport * 0.9;
expect(tabletWidth).toBeLessThanOrEqual(expected90vw + 5);
expect(tabletWidth).toBeGreaterThanOrEqual(expected90vw - 5);
});
test('should update modal size when resizing from tablet to mobile', async ({ page }) => {
await setupAndOpenModal(page);
// Start with tablet size
await page.setViewportSize({ width: 768, height: 1024 });
await page.waitForTimeout(200);
const modal = page.locator('[data-testid="agent-output-modal"]');
const tabletWidth = await modal.evaluate((el) => el.offsetWidth);
const tabletViewport = 768;
// Tablet: ~90vw
expect(tabletWidth).toBeLessThanOrEqual(tabletViewport * 0.9 + 5);
// Resize to mobile
await page.setViewportSize({ width: 375, height: 667 });
await page.waitForTimeout(200);
const mobileWidth = await modal.evaluate((el) => el.offsetWidth);
const mobileViewport = 375;
// Mobile: close to full width
expect(mobileWidth).toBeGreaterThan(mobileViewport - 40);
});
});
test.describe('Content Responsiveness', () => {
test('should display content correctly on tablet view', async ({ page }) => {
await setupAndOpenModal(page);
await page.setViewportSize({ width: 768, height: 1024 });
await page.waitForTimeout(200);
// Modal should be visible
const modal = page.locator('[data-testid="agent-output-modal"]');
await expect(modal).toBeVisible();
// Description should be visible
const description = modal.locator('[data-testid="agent-output-description"]');
await expect(description).toBeVisible();
});
test('should maintain readability on tablet with wider width', async ({ page }) => {
await setupAndOpenModal(page);
await page.setViewportSize({ width: 1200, height: 800 });
await page.waitForTimeout(200);
const modal = page.locator('[data-testid="agent-output-modal"]');
const modalWidth = await modal.evaluate((el) => el.offsetWidth);
// At 1200px, max-width is 1200px so modal should not exceed that
expect(modalWidth).toBeLessThanOrEqual(1200);
expect(modalWidth).toBeGreaterThan(0);
});
});
test.describe('Modal Functionality Across Screens', () => {
test('should maintain functionality while resizing', async ({ page }) => {
await setupAndOpenModal(page);
// Test on mobile
await page.setViewportSize({ width: 375, height: 667 });
await page.waitForTimeout(200);
await expect(page.locator('[data-testid="agent-output-modal"]')).toBeVisible();
// Test on tablet
await page.setViewportSize({ width: 768, height: 1024 });
await page.waitForTimeout(200);
await expect(page.locator('[data-testid="agent-output-modal"]')).toBeVisible();
// Close modal and verify
await page.keyboard.press('Escape');
await expect(page.locator('[data-testid="agent-output-modal"]')).not.toBeVisible({
timeout: 5000,
});
});
test('should handle view mode buttons on tablet', async ({ page }) => {
await setupAndOpenModal(page);
await page.setViewportSize({ width: 768, height: 1024 });
await page.waitForTimeout(200);
// Logs button should be visible and clickable
const logsButton = page.getByTestId('view-mode-parsed');
await expect(logsButton).toBeVisible();
// Raw button should be visible
const rawButton = page.getByTestId('view-mode-raw');
await expect(rawButton).toBeVisible();
});
});
});

View File

@@ -0,0 +1,244 @@
/**
* E2E test for success log output contrast improvement
* Verifies that success tool output has better visual contrast in the parsed log view
*/
import { test, expect } from '@playwright/test';
import * as fs from 'fs';
import * as path from 'path';
import {
createTempDirPath,
cleanupTempDir,
setupRealProject,
waitForNetworkIdle,
authenticateForTests,
handleLoginScreenIfPresent,
} from '../utils';
/**
* Create a test feature with agent output for contrast verification
*/
function createTestFeature(
projectPath: string,
featureId: string,
outputContent: string,
title: string = 'Test Success Contrast',
description: string = 'Testing success log output contrast'
): void {
const featureDir = path.join(projectPath, '.automaker', 'features', featureId);
fs.mkdirSync(featureDir, { recursive: true });
// Write agent output
fs.writeFileSync(path.join(featureDir, 'agent-output.md'), outputContent, {
encoding: 'utf-8',
});
// Write feature metadata with all required fields
const featureData = {
id: featureId,
title,
category: 'default',
description,
status: 'verified',
};
fs.writeFileSync(path.join(featureDir, 'feature.json'), JSON.stringify(featureData, null, 2), {
encoding: 'utf-8',
});
}
const TEST_TEMP_DIR = createTempDirPath('success-log-contrast');
test.describe('Success log output contrast', () => {
let projectPath: string;
const projectName = `test-contrast-${Date.now()}`;
test.beforeAll(async () => {
if (!fs.existsSync(TEST_TEMP_DIR)) {
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
}
projectPath = path.join(TEST_TEMP_DIR, projectName);
fs.mkdirSync(projectPath, { recursive: true });
// Create minimal project structure
fs.writeFileSync(
path.join(projectPath, 'package.json'),
JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2)
);
// Create .automaker directory structure
const automakerDir = path.join(projectPath, '.automaker');
fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true });
fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true });
fs.writeFileSync(
path.join(automakerDir, 'categories.json'),
JSON.stringify({ categories: [] }, null, 2)
);
fs.writeFileSync(
path.join(automakerDir, 'app_spec.txt'),
`# ${projectName}\n\nA test project for success log contrast verification.`
);
});
test.afterAll(async () => {
cleanupTempDir(TEST_TEMP_DIR);
});
/**
* Helper: set up project, create a verified feature, navigate to board,
* and open the agent output modal with the parsed/logs view active.
*/
async function setupAndOpenLogsView(
page: import('@playwright/test').Page,
featureId: string,
outputContent: string,
title: string,
description: string
): Promise<void> {
createTestFeature(projectPath, featureId, outputContent, title, description);
await setupRealProject(page, projectPath, projectName, { setAsCurrent: true });
await authenticateForTests(page);
await page.goto('/board');
await page.waitForLoadState('load');
await handleLoginScreenIfPresent(page);
await waitForNetworkIdle(page);
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 10000 });
// Wait for the verified feature card to appear
const featureCard = page.locator(`[data-testid="kanban-card-${featureId}"]`);
await expect(featureCard).toBeVisible({ timeout: 10000 });
// Click the Logs button on the verified feature card
const logsButton = page.locator(`[data-testid="view-output-verified-${featureId}"]`);
await expect(logsButton).toBeVisible({ timeout: 5000 });
await logsButton.click();
// Wait for modal to open
const modal = page.locator('[data-testid="agent-output-modal"]');
await expect(modal).toBeVisible({ timeout: 10000 });
// The modal opens in Logs view by default. Verify the Logs tab is active.
const parsedButton = page.getByTestId('view-mode-parsed');
await expect(parsedButton).toBeVisible({ timeout: 5000 });
}
test('should display success log output with improved contrast', async ({ page }) => {
const testFeatureId = `test-success-contrast-${Date.now()}`;
const mockOutput = `## Summary
Successfully implemented the feature with improved contrast.
## Action Phase
✓ Created component with proper styling
✓ Verified success message contrast is improved
✓ All tests passing
The feature is complete and ready for review.
`;
await setupAndOpenLogsView(
page,
testFeatureId,
mockOutput,
'Test Success Contrast',
'Testing success log output contrast'
);
const modal = page.locator('[data-testid="agent-output-modal"]');
// Verify the modal shows the parsed log view with log entries
// The log viewer should display entries parsed from the agent output
// Use .first() because "Summary" appears in both the badge and the content preview
await expect(modal.locator('text=Summary').first()).toBeVisible({ timeout: 5000 });
// Verify the description is shown
await expect(modal.locator('text=Testing success log output contrast')).toBeVisible();
// Close modal
await page.keyboard.press('Escape');
await expect(modal).not.toBeVisible({ timeout: 5000 });
});
test('should maintain consistency across all log types', async ({ page }) => {
const testFeatureId = `test-all-logs-${Date.now()}`;
const mixedOutput = `## Planning Phase
Analyzing requirements and creating implementation plan.
## Development Phase
Creating components and implementing features.
## Testing Phase
Running tests and verifying functionality.
## Summary
Feature implementation complete with all tests passing.
`;
await setupAndOpenLogsView(
page,
testFeatureId,
mixedOutput,
'Test All Logs',
'Testing all log types'
);
const modal = page.locator('[data-testid="agent-output-modal"]');
// Verify log entries are displayed in the parsed view
// Use .first() because "Summary" appears in both the badge and the content preview
await expect(modal.locator('text=Summary').first()).toBeVisible({ timeout: 5000 });
// Verify the description is shown
await expect(modal.locator('text=Testing all log types')).toBeVisible();
// Close modal
await page.keyboard.press('Escape');
await expect(modal).not.toBeVisible({ timeout: 5000 });
});
test('should have consistent badge styling with improved contrast', async ({ page }) => {
const testFeatureId = `test-badge-contrast-${Date.now()}`;
const badgeOutput = `## Summary
✅ Component created successfully
✅ Tests passing with improved contrast
✅ Ready for deployment
All tasks completed successfully.
`;
await setupAndOpenLogsView(
page,
testFeatureId,
badgeOutput,
'Test Badge Contrast',
'Testing badge contrast in success logs'
);
const modal = page.locator('[data-testid="agent-output-modal"]');
// Verify the parsed log view shows content
await expect(modal.locator('text=Summary')).toBeVisible({ timeout: 5000 });
// Verify the description is shown
await expect(modal.locator('text=Testing badge contrast in success logs')).toBeVisible();
// Verify the filter badges are displayed (showing log type counts)
// The log viewer shows filter badges like "success: 1" to indicate log types
const filterSection = modal.locator('button:has-text("success")');
if (await filterSection.isVisible({ timeout: 2000 }).catch(() => false)) {
// Success filter badge is present, indicating logs were categorized correctly
await expect(filterSection).toBeVisible();
}
// Close modal
await page.keyboard.press('Escape');
await expect(modal).not.toBeVisible({ timeout: 5000 });
});
});

View File

@@ -8,7 +8,10 @@
import { chromium, FullConfig } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import { cleanupLeftoverTestDirs } from './utils/cleanup-test-dirs';
import {
cleanupLeftoverFixtureWorkerDirs,
cleanupLeftoverTestDirs,
} from './utils/cleanup-test-dirs';
const TEST_PORT = process.env.TEST_PORT || '3107';
const TEST_SERVER_PORT = process.env.TEST_SERVER_PORT || '3108';
@@ -19,8 +22,9 @@ const AUTH_DIR = path.join(__dirname, '.auth');
const AUTH_STATE_PATH = path.join(AUTH_DIR, 'storage-state.json');
async function globalSetup(config: FullConfig) {
// Clean up leftover test dirs from previous runs (aborted, crashed, etc.)
// Clean up leftover test dirs and fixture worker copies from previous runs (aborted, crashed, etc.)
cleanupLeftoverTestDirs();
cleanupLeftoverFixtureWorkerDirs();
// Note: Server killing is handled by the pretest script in package.json
// GlobalSetup runs AFTER webServer starts, so we can't kill the server here

View File

@@ -6,10 +6,14 @@
*/
import { FullConfig } from '@playwright/test';
import { cleanupLeftoverTestDirs } from './utils/cleanup-test-dirs';
import {
cleanupLeftoverFixtureWorkerDirs,
cleanupLeftoverTestDirs,
} from './utils/cleanup-test-dirs';
async function globalTeardown(_config: FullConfig) {
cleanupLeftoverTestDirs();
cleanupLeftoverFixtureWorkerDirs();
console.log('[GlobalTeardown] Cleanup complete');
}

View File

@@ -6,10 +6,10 @@
* 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 { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useAppStore } from '@/store/app-store';
// Mock the store

View File

@@ -1,7 +1,8 @@
/**
* Cleanup leftover E2E test artifact directories.
* Used by globalSetup (start of run) and globalTeardown (end of run) to ensure
* test/board-bg-test-*, test/edit-feature-test-*, etc. are removed.
* Used by globalSetup (start of run) and globalTeardown (end of run) to ensure:
* - test/board-bg-test-*, test/edit-feature-test-*, etc. are removed
* - test/fixtures/.worker-* (worker-isolated fixture copies) are removed
*
* Per-spec afterAll hooks clean up their own dirs, but when workers crash,
* runs are aborted, or afterAll fails, dirs can be left behind.
@@ -25,9 +26,33 @@ const TEST_DIR_PREFIXES = [
'skip-tests-toggle-test',
'manual-review-test',
'feature-backlog-test',
'agent-output-modal-responsive',
'responsive-modal-test',
'success-log-contrast',
] as const;
/**
* Remove worker-isolated fixture copies (test/fixtures/.worker-*).
* These are created during test runs for parallel workers and should be
* cleaned up after tests complete (or at start of next run).
*/
export function cleanupLeftoverFixtureWorkerDirs(): void {
const fixturesBase = path.join(getWorkspaceRoot(), 'test', 'fixtures');
if (!fs.existsSync(fixturesBase)) return;
const entries = fs.readdirSync(fixturesBase, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith('.worker-')) {
const dirPath = path.join(fixturesBase, entry.name);
try {
fs.rmSync(dirPath, { recursive: true, force: true });
console.log('[Cleanup] Removed fixture worker dir', entry.name);
} catch (err) {
console.warn('[Cleanup] Failed to remove', dirPath, err);
}
}
}
}
export function cleanupLeftoverTestDirs(): void {
const testBase = path.join(getWorkspaceRoot(), 'test');
if (!fs.existsSync(testBase)) return;

View File

@@ -1,282 +0,0 @@
/**
* Responsive testing utilities for modal components
* These utilities help test responsive behavior across different screen sizes
*/
import { Page, expect } from '@playwright/test';
import { waitForElement } from '../core/waiting';
/**
* Wait for viewport resize to stabilize by polling element dimensions
* until they stop changing. Much more reliable than a fixed timeout.
*/
async function waitForLayoutStable(page: Page, testId: string, timeout = 2000): Promise<void> {
await page.waitForFunction(
({ testId: tid, timeout: t }) => {
return new Promise<boolean>((resolve) => {
const el = document.querySelector(`[data-testid="${tid}"]`);
if (!el) {
resolve(true);
return;
}
let lastWidth = el.clientWidth;
let lastHeight = el.clientHeight;
let stableCount = 0;
const interval = setInterval(() => {
const w = el.clientWidth;
const h = el.clientHeight;
if (w === lastWidth && h === lastHeight) {
stableCount++;
if (stableCount >= 3) {
clearInterval(interval);
resolve(true);
}
} else {
stableCount = 0;
lastWidth = w;
lastHeight = h;
}
}, 50);
setTimeout(() => {
clearInterval(interval);
resolve(true);
}, t);
});
},
{ testId, timeout },
{ timeout: timeout + 500 }
);
}
/**
* Viewport sizes for different device types
*/
export const VIEWPORTS = {
mobile: { width: 375, height: 667 },
mobileLarge: { width: 414, height: 896 },
tablet: { width: 768, height: 1024 },
tabletLarge: { width: 1024, height: 1366 },
desktop: { width: 1280, height: 720 },
desktopLarge: { width: 1920, height: 1080 },
} as const;
/**
* Expected responsive classes for AgentOutputModal
*/
export const EXPECTED_CLASSES = {
mobile: {
width: ['w-full', 'max-w-[calc(100%-2rem)]'],
height: ['max-h-[85dvh]'],
},
small: {
width: ['sm:w-[60vw]', 'sm:max-w-[60vw]'],
height: ['sm:max-h-[80vh]'],
},
tablet: {
width: ['md:w-[90vw]', 'md:max-w-[1200px]'],
height: ['md:max-h-[85vh]'],
},
} as const;
/**
* Get the computed width of the modal in pixels
*/
export async function getModalWidth(page: Page): Promise<number> {
const modal = page.locator('[data-testid="agent-output-modal"]');
return await modal.evaluate((el) => el.offsetWidth);
}
/**
* Get the computed height of the modal in pixels
*/
export async function getModalHeight(page: Page): Promise<number> {
const modal = page.locator('[data-testid="agent-output-modal"]');
return await modal.evaluate((el) => el.offsetHeight);
}
/**
* Get the computed style properties of the modal
*/
export async function getModalComputedStyle(page: Page): Promise<{
width: string;
height: string;
maxWidth: string;
maxHeight: string;
}> {
const modal = page.locator('[data-testid="agent-output-modal"]');
return await modal.evaluate((el) => {
const style = window.getComputedStyle(el);
return {
width: style.width,
height: style.height,
maxWidth: style.maxWidth,
maxHeight: style.maxHeight,
};
});
}
/**
* Check if modal has expected classes for a specific viewport
*/
export async function expectModalResponsiveClasses(
page: Page,
viewport: keyof typeof VIEWPORTS,
expectedClasses: string[]
): Promise<void> {
const modal = page.locator('[data-testid="agent-output-modal"]');
for (const className of expectedClasses) {
await expect(modal).toContainClass(className);
}
}
/**
* Test modal width across different viewports
*/
export async function testModalWidthAcrossViewports(
page: Page,
viewports: Array<keyof typeof VIEWPORTS>
): Promise<void> {
for (const viewport of viewports) {
const size = VIEWPORTS[viewport];
// Set viewport
await page.setViewportSize(size);
// Wait for any responsive transitions
await waitForLayoutStable(page, 'agent-output-modal');
// Get modal width
const modalWidth = await getModalWidth(page);
const viewportWidth = size.width;
// Check constraints based on viewport
if (viewport === 'mobile' || viewport === 'mobileLarge') {
// Mobile: should be close to full width with 2rem margins
expect(modalWidth).toBeGreaterThan(viewportWidth - 40);
expect(modalWidth).toBeLessThan(viewportWidth - 20);
} else if (viewport === 'tablet' || viewport === 'tabletLarge') {
// Tablet: should be around 90vw but not exceed max-w-[1200px]
const expected90vw = Math.floor(viewportWidth * 0.9);
expect(modalWidth).toBeLessThanOrEqual(expected90vw);
expect(modalWidth).toBeLessThanOrEqual(1200);
} else if (viewport === 'desktop' || viewport === 'desktopLarge') {
// Desktop: should be bounded by viewport and max-width constraints
const expectedMaxWidth = Math.floor(viewportWidth * 0.9);
const modalHeight = await getModalHeight(page);
const viewportHeight = size.height;
const expectedMaxHeight = Math.floor(viewportHeight * 0.9);
expect(modalWidth).toBeLessThanOrEqual(expectedMaxWidth);
expect(modalWidth).toBeLessThanOrEqual(1200);
expect(modalWidth).toBeGreaterThan(0);
expect(modalHeight).toBeLessThanOrEqual(expectedMaxHeight);
expect(modalHeight).toBeGreaterThan(0);
}
}
}
/**
* Test modal height across different viewports
*/
export async function testModalHeightAcrossViewports(
page: Page,
viewports: Array<keyof typeof VIEWPORTS>
): Promise<void> {
for (const viewport of viewports) {
const size = VIEWPORTS[viewport];
// Set viewport
await page.setViewportSize(size);
// Wait for any responsive transitions
await waitForLayoutStable(page, 'agent-output-modal');
// Get modal height
const modalHeight = await getModalHeight(page);
const viewportHeight = size.height;
// Check constraints based on viewport
if (viewport === 'mobile' || viewport === 'mobileLarge') {
// Mobile: should be max-h-[85dvh]
const expected85dvh = Math.floor(viewportHeight * 0.85);
expect(modalHeight).toBeLessThanOrEqual(expected85dvh);
} else if (viewport === 'tablet' || viewport === 'tabletLarge') {
// Tablet: should be max-h-[85vh]
const expected85vh = Math.floor(viewportHeight * 0.85);
expect(modalHeight).toBeLessThanOrEqual(expected85vh);
}
}
}
/**
* Test modal responsiveness during resize
*/
export async function testModalResponsiveResize(
page: Page,
fromViewport: keyof typeof VIEWPORTS,
toViewport: keyof typeof VIEWPORTS
): Promise<void> {
// Set initial viewport
await page.setViewportSize(VIEWPORTS[fromViewport]);
await waitForLayoutStable(page, 'agent-output-modal');
// Get initial modal dimensions (used for comparison context)
await getModalComputedStyle(page);
// Resize to new viewport
await page.setViewportSize(VIEWPORTS[toViewport]);
await waitForLayoutStable(page, 'agent-output-modal');
// Get new modal dimensions
const newDimensions = await getModalComputedStyle(page);
// Verify dimensions changed appropriately using resolved pixel values
const toSize = VIEWPORTS[toViewport];
if (fromViewport === 'mobile' && toViewport === 'tablet') {
const widthPx = parseFloat(newDimensions.width);
const maxWidthPx = parseFloat(newDimensions.maxWidth);
const expected90vw = toSize.width * 0.9;
expect(widthPx).toBeLessThanOrEqual(expected90vw + 2);
expect(maxWidthPx).toBeGreaterThanOrEqual(1200);
} else if (fromViewport === 'tablet' && toViewport === 'mobile') {
const widthPx = parseFloat(newDimensions.width);
const maxWidthPx = parseFloat(newDimensions.maxWidth);
expect(widthPx).toBeGreaterThan(toSize.width - 60);
expect(maxWidthPx).toBeLessThan(1200);
}
}
/**
* Verify modal maintains functionality across viewports
*/
export async function verifyModalFunctionalityAcrossViewports(
page: Page,
viewports: Array<keyof typeof VIEWPORTS>
): Promise<void> {
for (const viewport of viewports) {
const size = VIEWPORTS[viewport];
// Set viewport
await page.setViewportSize(size);
await waitForLayoutStable(page, 'agent-output-modal');
// Verify modal is visible
const modal = await waitForElement(page, 'agent-output-modal');
await expect(modal).toBeVisible();
// Verify modal content is visible
const description = page.locator('[data-testid="agent-output-description"]');
await expect(description).toBeVisible();
// Verify view mode buttons are visible
if (
viewport === 'tablet' ||
viewport === 'tabletLarge' ||
viewport === 'desktop' ||
viewport === 'desktopLarge'
) {
const logsButton = page.getByTestId('view-mode-parsed');
await expect(logsButton).toBeVisible();
}
}
}

View File

@@ -1,23 +0,0 @@
import * as os from 'os';
import * as path from 'path';
import * as fs from 'fs';
/**
* Create a deterministic temp directory path for a test suite.
* The directory is NOT created on disk — call fs.mkdirSync in beforeAll.
*/
export function createTempDirPath(prefix: string): string {
return path.join(os.tmpdir(), `automaker-test-${prefix}-${process.pid}`);
}
/**
* Remove a temp directory and all its contents.
* Silently ignores errors (e.g. directory already removed).
*/
export function cleanupTempDir(dirPath: string): void {
try {
fs.rmSync(dirPath, { recursive: true, force: true });
} catch {
// Ignore cleanup errors
}
}