mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-19 10:43:08 +00:00
Fix agent output summary for pipeline steps (#812)
* Changes from fix/agent-output-summary-for-pipeline-steps * feat: Optimize pipeline summary extraction and fix regex vulnerability * fix: Use fallback summary for pipeline steps when extraction fails * fix: Strip follow-up session scaffold from pipeline step fallback summaries
This commit is contained in:
563
apps/server/tests/unit/ui/agent-output-summary-e2e.test.ts
Normal file
563
apps/server/tests/unit/ui/agent-output-summary-e2e.test.ts
Normal file
@@ -0,0 +1,563 @@
|
||||
/**
|
||||
* End-to-end integration tests for agent output summary display flow.
|
||||
*
|
||||
* These tests validate the complete flow from:
|
||||
* 1. Server-side summary accumulation (FeatureStateManager.saveFeatureSummary)
|
||||
* 2. Event emission with accumulated summary (auto_mode_summary event)
|
||||
* 3. UI-side summary retrieval (feature.summary via API)
|
||||
* 4. UI-side summary parsing and display (parsePhaseSummaries, extractSummary)
|
||||
*
|
||||
* The tests simulate what happens when:
|
||||
* - A feature goes through multiple pipeline steps
|
||||
* - Each step produces a summary
|
||||
* - The server accumulates all summaries
|
||||
* - The UI displays the accumulated summary
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
|
||||
import { FeatureStateManager } from '@/services/feature-state-manager.js';
|
||||
import type { Feature } from '@automaker/types';
|
||||
import type { EventEmitter } from '@/lib/events.js';
|
||||
import type { FeatureLoader } from '@/services/feature-loader.js';
|
||||
import { atomicWriteJson, readJsonWithRecovery } from '@automaker/utils';
|
||||
import { getFeatureDir } from '@automaker/platform';
|
||||
import { pipelineService } from '@/services/pipeline-service.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/lib/secure-fs.js', () => ({
|
||||
readFile: vi.fn(),
|
||||
readdir: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@automaker/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@automaker/utils')>();
|
||||
return {
|
||||
...actual,
|
||||
atomicWriteJson: vi.fn(),
|
||||
readJsonWithRecovery: vi.fn(),
|
||||
logRecoveryWarning: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@automaker/platform', () => ({
|
||||
getFeatureDir: vi.fn(),
|
||||
getFeaturesDir: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/notification-service.js', () => ({
|
||||
getNotificationService: vi.fn(() => ({
|
||||
createNotification: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/pipeline-service.js', () => ({
|
||||
pipelineService: {
|
||||
getStepIdFromStatus: vi.fn((status: string) => {
|
||||
if (status.startsWith('pipeline_')) return status.replace('pipeline_', '');
|
||||
return null;
|
||||
}),
|
||||
getStep: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// ============================================================================
|
||||
// UI-side parsing functions (mirrored from apps/ui/src/lib/log-parser.ts)
|
||||
// ============================================================================
|
||||
|
||||
function parsePhaseSummaries(summary: string | undefined): Map<string, string> {
|
||||
const phaseSummaries = new Map<string, string>();
|
||||
if (!summary || !summary.trim()) return phaseSummaries;
|
||||
|
||||
const sections = summary.split(/\n\n---\n\n/);
|
||||
for (const section of sections) {
|
||||
const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/);
|
||||
if (headerMatch) {
|
||||
const phaseName = headerMatch[1].trim().toLowerCase();
|
||||
const content = section.substring(headerMatch[0].length).trim();
|
||||
phaseSummaries.set(phaseName, content);
|
||||
}
|
||||
}
|
||||
return phaseSummaries;
|
||||
}
|
||||
|
||||
function extractSummary(rawOutput: string): string | null {
|
||||
if (!rawOutput || !rawOutput.trim()) return null;
|
||||
|
||||
const regexesToTry: Array<{
|
||||
regex: RegExp;
|
||||
processor: (m: RegExpMatchArray) => string;
|
||||
}> = [
|
||||
{ regex: /<summary>([\s\S]*?)<\/summary>/gi, processor: (m) => m[1] },
|
||||
{ regex: /^##\s+Summary[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, processor: (m) => m[1] },
|
||||
];
|
||||
|
||||
for (const { regex, processor } of regexesToTry) {
|
||||
const matches = [...rawOutput.matchAll(regex)];
|
||||
if (matches.length > 0) {
|
||||
const lastMatch = matches[matches.length - 1];
|
||||
return processor(lastMatch).trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isAccumulatedSummary(summary: string | undefined): boolean {
|
||||
if (!summary || !summary.trim()) return false;
|
||||
return summary.includes('\n\n---\n\n') && (summary.match(/###\s+.+/g)?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first summary candidate that contains non-whitespace content.
|
||||
* Mirrors getFirstNonEmptySummary from apps/ui/src/lib/summary-selection.ts
|
||||
*/
|
||||
function getFirstNonEmptySummary(...candidates: (string | null | undefined)[]): string | null {
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === 'string' && candidate.trim().length > 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Unit tests for helper functions
|
||||
// ============================================================================
|
||||
|
||||
describe('getFirstNonEmptySummary', () => {
|
||||
it('should return the first non-empty string', () => {
|
||||
expect(getFirstNonEmptySummary(null, undefined, 'first', 'second')).toBe('first');
|
||||
});
|
||||
|
||||
it('should skip null and undefined candidates', () => {
|
||||
expect(getFirstNonEmptySummary(null, undefined, 'valid')).toBe('valid');
|
||||
});
|
||||
|
||||
it('should skip whitespace-only strings', () => {
|
||||
expect(getFirstNonEmptySummary(' ', '\n\t', 'actual content')).toBe('actual content');
|
||||
});
|
||||
|
||||
it('should return null when all candidates are empty', () => {
|
||||
expect(getFirstNonEmptySummary(null, undefined, '', ' ')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when no candidates provided', () => {
|
||||
expect(getFirstNonEmptySummary()).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty string as invalid', () => {
|
||||
expect(getFirstNonEmptySummary('', 'valid')).toBe('valid');
|
||||
});
|
||||
|
||||
it('should prefer first valid candidate', () => {
|
||||
expect(getFirstNonEmptySummary('first', 'second', 'third')).toBe('first');
|
||||
});
|
||||
|
||||
it('should handle strings with only spaces as invalid', () => {
|
||||
expect(getFirstNonEmptySummary(' ', ' \n ', 'valid')).toBe('valid');
|
||||
});
|
||||
|
||||
it('should accept strings with content surrounded by whitespace', () => {
|
||||
expect(getFirstNonEmptySummary(' content with spaces ')).toBe(' content with spaces ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Output Summary E2E Flow', () => {
|
||||
let manager: FeatureStateManager;
|
||||
let mockEvents: EventEmitter;
|
||||
|
||||
const baseFeature: Feature = {
|
||||
id: 'e2e-feature-1',
|
||||
name: 'E2E Feature',
|
||||
title: 'E2E Feature Title',
|
||||
description: 'A feature going through complete pipeline',
|
||||
status: 'pipeline_implementation',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockEvents = {
|
||||
emit: vi.fn(),
|
||||
subscribe: vi.fn(() => vi.fn()),
|
||||
};
|
||||
|
||||
const mockFeatureLoader = {
|
||||
syncFeatureToAppSpec: vi.fn(),
|
||||
} as unknown as FeatureLoader;
|
||||
|
||||
manager = new FeatureStateManager(mockEvents, mockFeatureLoader);
|
||||
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
|
||||
});
|
||||
|
||||
describe('complete pipeline flow: server accumulation → UI display', () => {
|
||||
it('should maintain complete summary across all pipeline steps', async () => {
|
||||
// ===== STEP 1: Implementation =====
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({
|
||||
name: 'Implementation',
|
||||
id: 'implementation',
|
||||
});
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary(
|
||||
'/project',
|
||||
'e2e-feature-1',
|
||||
'## Changes\n- Created auth module\n- Added user service'
|
||||
);
|
||||
|
||||
const step1Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
const step1Summary = step1Feature.summary;
|
||||
|
||||
// Verify server-side accumulation format
|
||||
expect(step1Summary).toBe(
|
||||
'### Implementation\n\n## Changes\n- Created auth module\n- Added user service'
|
||||
);
|
||||
|
||||
// Verify UI can parse this summary
|
||||
const phases1 = parsePhaseSummaries(step1Summary);
|
||||
expect(phases1.size).toBe(1);
|
||||
expect(phases1.get('implementation')).toContain('Created auth module');
|
||||
|
||||
// ===== STEP 2: Code Review =====
|
||||
vi.clearAllMocks();
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({
|
||||
name: 'Code Review',
|
||||
id: 'code_review',
|
||||
});
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_code_review', summary: step1Summary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary(
|
||||
'/project',
|
||||
'e2e-feature-1',
|
||||
'## Review Results\n- Approved with minor suggestions'
|
||||
);
|
||||
|
||||
const step2Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
const step2Summary = step2Feature.summary;
|
||||
|
||||
// Verify accumulation now has both steps
|
||||
expect(step2Summary).toContain('### Implementation');
|
||||
expect(step2Summary).toContain('Created auth module');
|
||||
expect(step2Summary).toContain('### Code Review');
|
||||
expect(step2Summary).toContain('Approved with minor suggestions');
|
||||
expect(step2Summary).toContain('\n\n---\n\n'); // Separator
|
||||
|
||||
// Verify UI can parse accumulated summary
|
||||
expect(isAccumulatedSummary(step2Summary)).toBe(true);
|
||||
const phases2 = parsePhaseSummaries(step2Summary);
|
||||
expect(phases2.size).toBe(2);
|
||||
expect(phases2.get('implementation')).toContain('Created auth module');
|
||||
expect(phases2.get('code review')).toContain('Approved with minor suggestions');
|
||||
|
||||
// ===== STEP 3: Testing =====
|
||||
vi.clearAllMocks();
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'testing' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_testing', summary: step2Summary },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary(
|
||||
'/project',
|
||||
'e2e-feature-1',
|
||||
'## Test Results\n- 42 tests pass\n- 98% coverage'
|
||||
);
|
||||
|
||||
const finalFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
|
||||
const finalSummary = finalFeature.summary;
|
||||
|
||||
// Verify final accumulation has all three steps
|
||||
expect(finalSummary).toContain('### Implementation');
|
||||
expect(finalSummary).toContain('Created auth module');
|
||||
expect(finalSummary).toContain('### Code Review');
|
||||
expect(finalSummary).toContain('Approved with minor suggestions');
|
||||
expect(finalSummary).toContain('### Testing');
|
||||
expect(finalSummary).toContain('42 tests pass');
|
||||
|
||||
// Verify UI-side parsing of complete pipeline
|
||||
expect(isAccumulatedSummary(finalSummary)).toBe(true);
|
||||
const finalPhases = parsePhaseSummaries(finalSummary);
|
||||
expect(finalPhases.size).toBe(3);
|
||||
|
||||
// Verify chronological order (implementation before testing)
|
||||
const summaryLines = finalSummary!.split('\n');
|
||||
const implIndex = summaryLines.findIndex((l) => l.includes('### Implementation'));
|
||||
const reviewIndex = summaryLines.findIndex((l) => l.includes('### Code Review'));
|
||||
const testIndex = summaryLines.findIndex((l) => l.includes('### Testing'));
|
||||
expect(implIndex).toBeLessThan(reviewIndex);
|
||||
expect(reviewIndex).toBeLessThan(testIndex);
|
||||
});
|
||||
|
||||
it('should emit events with accumulated summaries for real-time UI updates', async () => {
|
||||
// Step 1
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({
|
||||
name: 'Implementation',
|
||||
id: 'implementation',
|
||||
});
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Step 1 output');
|
||||
|
||||
// Verify event emission
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
||||
type: 'auto_mode_summary',
|
||||
featureId: 'e2e-feature-1',
|
||||
projectPath: '/project',
|
||||
summary: '### Implementation\n\nStep 1 output',
|
||||
});
|
||||
|
||||
// Step 2
|
||||
vi.clearAllMocks();
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'testing' });
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: {
|
||||
...baseFeature,
|
||||
status: 'pipeline_testing',
|
||||
summary: '### Implementation\n\nStep 1 output',
|
||||
},
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Step 2 output');
|
||||
|
||||
// Event should contain FULL accumulated summary
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
|
||||
type: 'auto_mode_summary',
|
||||
featureId: 'e2e-feature-1',
|
||||
projectPath: '/project',
|
||||
summary: '### Implementation\n\nStep 1 output\n\n---\n\n### Testing\n\nStep 2 output',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('UI display logic: feature.summary vs extractSummary()', () => {
|
||||
it('should prefer feature.summary (server-accumulated) over extractSummary() (last only)', () => {
|
||||
// Simulate what the server has accumulated
|
||||
const featureSummary = [
|
||||
'### Implementation',
|
||||
'',
|
||||
'## Changes',
|
||||
'- Created feature',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### Testing',
|
||||
'',
|
||||
'## Results',
|
||||
'- All tests pass',
|
||||
].join('\n');
|
||||
|
||||
// Simulate raw agent output (only contains last summary)
|
||||
const rawOutput = `
|
||||
Working on tests...
|
||||
|
||||
<summary>
|
||||
## Results
|
||||
- All tests pass
|
||||
</summary>
|
||||
`;
|
||||
|
||||
// UI logic: getFirstNonEmptySummary(feature?.summary, extractSummary(output))
|
||||
const displaySummary = getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput));
|
||||
|
||||
// Should use server-accumulated summary
|
||||
expect(displaySummary).toBe(featureSummary);
|
||||
expect(displaySummary).toContain('### Implementation');
|
||||
expect(displaySummary).toContain('### Testing');
|
||||
|
||||
// If server summary was missing, only last summary would be shown
|
||||
const fallbackSummary = extractSummary(rawOutput);
|
||||
expect(fallbackSummary).not.toContain('Implementation');
|
||||
expect(fallbackSummary).toContain('All tests pass');
|
||||
});
|
||||
|
||||
it('should handle legacy features without server accumulation', () => {
|
||||
// Legacy features have no feature.summary
|
||||
const featureSummary = undefined;
|
||||
|
||||
// Raw output contains the summary
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
## Implementation Complete
|
||||
- Created the feature
|
||||
- All tests pass
|
||||
</summary>
|
||||
`;
|
||||
|
||||
// UI logic: getFirstNonEmptySummary(feature?.summary, extractSummary(output))
|
||||
const displaySummary = getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput));
|
||||
|
||||
// Should fall back to client-side extraction
|
||||
expect(displaySummary).toContain('Implementation Complete');
|
||||
expect(displaySummary).toContain('All tests pass');
|
||||
});
|
||||
});
|
||||
|
||||
describe('error recovery and edge cases', () => {
|
||||
it('should gracefully handle pipeline interruption', async () => {
|
||||
// Step 1 completes
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({
|
||||
name: 'Implementation',
|
||||
id: 'implementation',
|
||||
});
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Implementation done');
|
||||
|
||||
const step1Summary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
|
||||
|
||||
// Pipeline gets interrupted (status changes but summary is preserved)
|
||||
// When user views the feature later, the summary should still be available
|
||||
expect(step1Summary).toBe('### Implementation\n\nImplementation done');
|
||||
|
||||
// UI can still parse the partial pipeline
|
||||
const phases = parsePhaseSummaries(step1Summary);
|
||||
expect(phases.size).toBe(1);
|
||||
expect(phases.get('implementation')).toBe('Implementation done');
|
||||
});
|
||||
|
||||
it('should handle very large accumulated summaries', async () => {
|
||||
// Generate large content for each step
|
||||
const generateLargeContent = (stepNum: number) => {
|
||||
const lines = [`## Step ${stepNum} Changes`];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
lines.push(
|
||||
`- Change ${i}: This is a detailed description of the change made during step ${stepNum}`
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
// Simulate 5 pipeline steps with large content
|
||||
let currentSummary: string | undefined = undefined;
|
||||
const stepNames = ['Planning', 'Implementation', 'Code Review', 'Testing', 'Refinement'];
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
vi.clearAllMocks();
|
||||
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({
|
||||
name: stepNames[i],
|
||||
id: stepNames[i].toLowerCase().replace(' ', '_'),
|
||||
});
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: {
|
||||
...baseFeature,
|
||||
status: `pipeline_${stepNames[i].toLowerCase().replace(' ', '_')}`,
|
||||
summary: currentSummary,
|
||||
},
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'e2e-feature-1', generateLargeContent(i + 1));
|
||||
|
||||
currentSummary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
|
||||
}
|
||||
|
||||
// Final summary should be large but still parseable
|
||||
expect(currentSummary!.length).toBeGreaterThan(5000);
|
||||
expect(isAccumulatedSummary(currentSummary)).toBe(true);
|
||||
|
||||
const phases = parsePhaseSummaries(currentSummary);
|
||||
expect(phases.size).toBe(5);
|
||||
|
||||
// Verify all steps are present
|
||||
for (const stepName of stepNames) {
|
||||
expect(phases.has(stepName.toLowerCase())).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('query invalidation simulation', () => {
|
||||
it('should trigger UI refetch on auto_mode_summary event', async () => {
|
||||
// This test documents the expected behavior:
|
||||
// When saveFeatureSummary is called, it emits auto_mode_summary event
|
||||
// The UI's use-query-invalidation.ts invalidates the feature query
|
||||
// This causes a refetch of the feature, getting the updated summary
|
||||
|
||||
(pipelineService.getStep as Mock).mockResolvedValue({
|
||||
name: 'Implementation',
|
||||
id: 'implementation',
|
||||
});
|
||||
(readJsonWithRecovery as Mock).mockResolvedValue({
|
||||
data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined },
|
||||
recovered: false,
|
||||
source: 'main',
|
||||
});
|
||||
|
||||
await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Summary content');
|
||||
|
||||
// Verify event was emitted (triggers React Query invalidation)
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'auto-mode:event',
|
||||
expect.objectContaining({
|
||||
type: 'auto_mode_summary',
|
||||
featureId: 'e2e-feature-1',
|
||||
summary: expect.any(String),
|
||||
})
|
||||
);
|
||||
|
||||
// The UI would then:
|
||||
// 1. Receive the event via WebSocket
|
||||
// 2. Invalidate the feature query
|
||||
// 3. Refetch the feature (GET /api/features/:id)
|
||||
// 4. Display the updated feature.summary
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* KEY E2E FLOW SUMMARY:
|
||||
*
|
||||
* 1. PIPELINE EXECUTION:
|
||||
* - Feature starts with status='pipeline_implementation'
|
||||
* - Agent runs and produces summary
|
||||
* - FeatureStateManager.saveFeatureSummary() accumulates with step header
|
||||
* - Status advances to 'pipeline_testing'
|
||||
* - Process repeats for each step
|
||||
*
|
||||
* 2. SERVER-SIDE ACCUMULATION:
|
||||
* - First step: `### Implementation\n\n<content>`
|
||||
* - Second step: `### Implementation\n\n<content>\n\n---\n\n### Testing\n\n<content>`
|
||||
* - Pattern continues with each step
|
||||
*
|
||||
* 3. EVENT EMISSION:
|
||||
* - auto_mode_summary event contains FULL accumulated summary
|
||||
* - UI receives event via WebSocket
|
||||
* - React Query invalidates feature query
|
||||
* - Feature is refetched with updated summary
|
||||
*
|
||||
* 4. UI DISPLAY:
|
||||
* - AgentOutputModal uses: getFirstNonEmptySummary(feature?.summary, extractSummary(output))
|
||||
* - feature.summary is preferred (contains all steps)
|
||||
* - extractSummary() is fallback (last summary only)
|
||||
* - parsePhaseSummaries() can split into individual phases for UI
|
||||
*
|
||||
* 5. FALLBACK FOR LEGACY:
|
||||
* - Old features may not have feature.summary
|
||||
* - UI falls back to extracting from raw output
|
||||
* - Only last summary is available in this case
|
||||
*/
|
||||
403
apps/server/tests/unit/ui/agent-output-summary-priority.test.ts
Normal file
403
apps/server/tests/unit/ui/agent-output-summary-priority.test.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* Unit tests for the agent output summary priority logic.
|
||||
*
|
||||
* These tests verify the summary display logic used in AgentOutputModal
|
||||
* where the UI must choose between server-accumulated summaries and
|
||||
* client-side extracted summaries.
|
||||
*
|
||||
* Priority order (from agent-output-modal.tsx):
|
||||
* 1. feature.summary (server-accumulated, contains all pipeline steps)
|
||||
* 2. extractSummary(output) (client-side fallback, last summary only)
|
||||
*
|
||||
* This priority is crucial for pipeline features where the server-side
|
||||
* accumulation provides the complete history of all step summaries.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
// Import the actual extractSummary function to ensure test behavior matches production
|
||||
import { extractSummary } from '../../../../ui/src/lib/log-parser.ts';
|
||||
import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts';
|
||||
|
||||
/**
|
||||
* Simulates the summary priority logic from AgentOutputModal.
|
||||
*
|
||||
* Priority:
|
||||
* 1. feature?.summary (server-accumulated)
|
||||
* 2. extractSummary(output) (client-side fallback)
|
||||
*/
|
||||
function getDisplaySummary(
|
||||
featureSummary: string | undefined | null,
|
||||
rawOutput: string
|
||||
): string | null {
|
||||
return getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput));
|
||||
}
|
||||
|
||||
describe('Agent Output Summary Priority Logic', () => {
|
||||
describe('priority order: feature.summary over extractSummary', () => {
|
||||
it('should use feature.summary when available (server-accumulated wins)', () => {
|
||||
const featureSummary = '### Step 1\n\nFirst step\n\n---\n\n### Step 2\n\nSecond step';
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
Only the last summary is extracted client-side
|
||||
</summary>
|
||||
`;
|
||||
|
||||
const result = getDisplaySummary(featureSummary, rawOutput);
|
||||
|
||||
// Server-accumulated summary should be used, not client-side extraction
|
||||
expect(result).toBe(featureSummary);
|
||||
expect(result).toContain('### Step 1');
|
||||
expect(result).toContain('### Step 2');
|
||||
expect(result).not.toContain('Only the last summary');
|
||||
});
|
||||
|
||||
it('should use client-side extractSummary when feature.summary is undefined', () => {
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
This is the only summary
|
||||
</summary>
|
||||
`;
|
||||
|
||||
const result = getDisplaySummary(undefined, rawOutput);
|
||||
|
||||
expect(result).toBe('This is the only summary');
|
||||
});
|
||||
|
||||
it('should use client-side extractSummary when feature.summary is null', () => {
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
Client-side extracted summary
|
||||
</summary>
|
||||
`;
|
||||
|
||||
const result = getDisplaySummary(null, rawOutput);
|
||||
|
||||
expect(result).toBe('Client-side extracted summary');
|
||||
});
|
||||
|
||||
it('should use client-side extractSummary when feature.summary is empty string', () => {
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
Fallback content
|
||||
</summary>
|
||||
`;
|
||||
|
||||
const result = getDisplaySummary('', rawOutput);
|
||||
|
||||
// Empty string is falsy, so fallback is used
|
||||
expect(result).toBe('Fallback content');
|
||||
});
|
||||
|
||||
it('should use client-side extractSummary when feature.summary is whitespace only', () => {
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
Fallback for whitespace summary
|
||||
</summary>
|
||||
`;
|
||||
|
||||
const result = getDisplaySummary(' \n ', rawOutput);
|
||||
|
||||
expect(result).toBe('Fallback for whitespace summary');
|
||||
});
|
||||
|
||||
it('should preserve original server summary formatting when non-empty after trim', () => {
|
||||
const featureSummary = '\n### Implementation\n\n- Added API route\n';
|
||||
|
||||
const result = getDisplaySummary(featureSummary, '');
|
||||
|
||||
expect(result).toBe(featureSummary);
|
||||
expect(result).toContain('### Implementation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pipeline step accumulation scenarios', () => {
|
||||
it('should display all pipeline steps when using server-accumulated summary', () => {
|
||||
// This simulates a feature that went through 3 pipeline steps
|
||||
const featureSummary = [
|
||||
'### Implementation',
|
||||
'',
|
||||
'## Changes',
|
||||
'- Created new module',
|
||||
'- Added tests',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### Code Review',
|
||||
'',
|
||||
'## Review Results',
|
||||
'- Approved with minor suggestions',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### Testing',
|
||||
'',
|
||||
'## Test Results',
|
||||
'- All 42 tests pass',
|
||||
'- Coverage: 98%',
|
||||
].join('\n');
|
||||
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
Only testing step visible in raw output
|
||||
</summary>
|
||||
`;
|
||||
|
||||
const result = getDisplaySummary(featureSummary, rawOutput);
|
||||
|
||||
// All pipeline steps should be visible
|
||||
expect(result).toContain('### Implementation');
|
||||
expect(result).toContain('### Code Review');
|
||||
expect(result).toContain('### Testing');
|
||||
expect(result).toContain('All 42 tests pass');
|
||||
});
|
||||
|
||||
it('should display only last summary when server-side accumulation not available', () => {
|
||||
// When feature.summary is not available, only the last summary is shown
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
Step 1: Implementation complete
|
||||
</summary>
|
||||
|
||||
---
|
||||
|
||||
<summary>
|
||||
Step 2: Code review complete
|
||||
</summary>
|
||||
|
||||
---
|
||||
|
||||
<summary>
|
||||
Step 3: Testing complete
|
||||
</summary>
|
||||
`;
|
||||
|
||||
const result = getDisplaySummary(undefined, rawOutput);
|
||||
|
||||
// Only the LAST summary should be shown (client-side fallback behavior)
|
||||
expect(result).toBe('Step 3: Testing complete');
|
||||
expect(result).not.toContain('Step 1');
|
||||
expect(result).not.toContain('Step 2');
|
||||
});
|
||||
|
||||
it('should handle single-step pipeline (no accumulation needed)', () => {
|
||||
const featureSummary = '### Implementation\n\nCreated the feature';
|
||||
const rawOutput = '';
|
||||
|
||||
const result = getDisplaySummary(featureSummary, rawOutput);
|
||||
|
||||
expect(result).toBe(featureSummary);
|
||||
expect(result).not.toContain('---'); // No separator for single step
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return null when both feature.summary and extractSummary are unavailable', () => {
|
||||
const rawOutput = 'No summary tags here, just regular output.';
|
||||
|
||||
const result = getDisplaySummary(undefined, rawOutput);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when rawOutput is empty and no feature summary', () => {
|
||||
const result = getDisplaySummary(undefined, '');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when rawOutput is whitespace only', () => {
|
||||
const result = getDisplaySummary(undefined, ' \n\n ');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should use client-side fallback when feature.summary is empty string (falsy)', () => {
|
||||
// Empty string is falsy in JavaScript, so fallback is correctly used.
|
||||
// This is the expected behavior - an empty summary has no value to display.
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
Fallback content when server summary is empty
|
||||
</summary>
|
||||
`;
|
||||
|
||||
// Empty string is falsy, so fallback is used
|
||||
const result = getDisplaySummary('', rawOutput);
|
||||
expect(result).toBe('Fallback content when server summary is empty');
|
||||
});
|
||||
|
||||
it('should behave identically when feature is null vs feature.summary is undefined', () => {
|
||||
// This test verifies that the behavior is consistent whether:
|
||||
// - The feature object itself is null/undefined
|
||||
// - The feature object exists but summary property is undefined
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
Client-side extracted summary
|
||||
</summary>
|
||||
`;
|
||||
|
||||
// Both scenarios should use client-side fallback
|
||||
const resultWithUndefined = getDisplaySummary(undefined, rawOutput);
|
||||
const resultWithNull = getDisplaySummary(null, rawOutput);
|
||||
|
||||
expect(resultWithUndefined).toBe('Client-side extracted summary');
|
||||
expect(resultWithNull).toBe('Client-side extracted summary');
|
||||
expect(resultWithUndefined).toBe(resultWithNull);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdown content preservation', () => {
|
||||
it('should preserve markdown formatting in server-accumulated summary', () => {
|
||||
const featureSummary = `### Code Review
|
||||
|
||||
## Changes Made
|
||||
- Fixed **critical bug** in \`parser.ts\`
|
||||
- Added \`validateInput()\` function
|
||||
|
||||
\`\`\`typescript
|
||||
const x = 1;
|
||||
\`\`\`
|
||||
|
||||
| Test | Result |
|
||||
|------|--------|
|
||||
| Unit | Pass |`;
|
||||
|
||||
const result = getDisplaySummary(featureSummary, '');
|
||||
|
||||
expect(result).toContain('**critical bug**');
|
||||
expect(result).toContain('`parser.ts`');
|
||||
expect(result).toContain('```typescript');
|
||||
expect(result).toContain('| Test | Result |');
|
||||
});
|
||||
|
||||
it('should preserve unicode in server-accumulated summary', () => {
|
||||
const featureSummary = '### Testing\n\n✅ 42 passed\n❌ 0 failed\n🎉 100% coverage';
|
||||
|
||||
const result = getDisplaySummary(featureSummary, '');
|
||||
|
||||
expect(result).toContain('✅');
|
||||
expect(result).toContain('❌');
|
||||
expect(result).toContain('🎉');
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world scenarios', () => {
|
||||
it('should handle typical pipeline feature with server accumulation', () => {
|
||||
// Simulates a real pipeline feature that went through Implementation → Testing
|
||||
const featureSummary = `### Implementation
|
||||
|
||||
## Changes Made
|
||||
- Created UserProfile component
|
||||
- Added authentication middleware
|
||||
- Updated API endpoints
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
## Test Results
|
||||
- Unit tests: 15 passed
|
||||
- Integration tests: 8 passed
|
||||
- E2E tests: 3 passed`;
|
||||
|
||||
const rawOutput = `
|
||||
Working on the feature...
|
||||
|
||||
<summary>
|
||||
## Test Results
|
||||
- Unit tests: 15 passed
|
||||
- Integration tests: 8 passed
|
||||
- E2E tests: 3 passed
|
||||
</summary>
|
||||
`;
|
||||
|
||||
const result = getDisplaySummary(featureSummary, rawOutput);
|
||||
|
||||
// Both steps should be visible
|
||||
expect(result).toContain('### Implementation');
|
||||
expect(result).toContain('### Testing');
|
||||
expect(result).toContain('UserProfile component');
|
||||
expect(result).toContain('15 passed');
|
||||
});
|
||||
|
||||
it('should handle non-pipeline feature (single summary)', () => {
|
||||
// Non-pipeline features have a single summary, no accumulation
|
||||
const featureSummary = '## Implementation Complete\n- Created the feature\n- All tests pass';
|
||||
const rawOutput = '';
|
||||
|
||||
const result = getDisplaySummary(featureSummary, rawOutput);
|
||||
|
||||
expect(result).toBe(featureSummary);
|
||||
expect(result).not.toContain('###'); // No step headers for non-pipeline
|
||||
});
|
||||
|
||||
it('should handle legacy feature without server summary (fallback)', () => {
|
||||
// Legacy features may not have feature.summary set
|
||||
const rawOutput = `
|
||||
<summary>
|
||||
Legacy implementation from before server-side accumulation
|
||||
</summary>
|
||||
`;
|
||||
|
||||
const result = getDisplaySummary(undefined, rawOutput);
|
||||
|
||||
expect(result).toBe('Legacy implementation from before server-side accumulation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('view mode determination logic', () => {
|
||||
/**
|
||||
* Simulates the effectiveViewMode logic from agent-output-modal.tsx line 86
|
||||
* Default to 'summary' if summary is available, otherwise 'parsed'
|
||||
*/
|
||||
function getEffectiveViewMode(
|
||||
viewMode: string | null,
|
||||
summary: string | null
|
||||
): 'summary' | 'parsed' {
|
||||
return (viewMode ?? (summary ? 'summary' : 'parsed')) as 'summary' | 'parsed';
|
||||
}
|
||||
|
||||
it('should default to summary view when server summary is available', () => {
|
||||
const summary = '### Implementation\n\nContent';
|
||||
const result = getEffectiveViewMode(null, summary);
|
||||
expect(result).toBe('summary');
|
||||
});
|
||||
|
||||
it('should default to summary view when client-side extraction succeeds', () => {
|
||||
const summary = 'Extracted from raw output';
|
||||
const result = getEffectiveViewMode(null, summary);
|
||||
expect(result).toBe('summary');
|
||||
});
|
||||
|
||||
it('should default to parsed view when no summary is available', () => {
|
||||
const result = getEffectiveViewMode(null, null);
|
||||
expect(result).toBe('parsed');
|
||||
});
|
||||
|
||||
it('should respect explicit view mode selection over default', () => {
|
||||
const summary = 'Summary is available';
|
||||
expect(getEffectiveViewMode('raw', summary)).toBe('raw');
|
||||
expect(getEffectiveViewMode('parsed', summary)).toBe('parsed');
|
||||
expect(getEffectiveViewMode('changes', summary)).toBe('changes');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* KEY ARCHITECTURE INSIGHT:
|
||||
*
|
||||
* The priority order (feature.summary > extractSummary(output)) is essential for
|
||||
* pipeline features because:
|
||||
*
|
||||
* 1. Server-side accumulation (FeatureStateManager.saveFeatureSummary) collects
|
||||
* ALL step summaries with headers and separators in chronological order.
|
||||
*
|
||||
* 2. Client-side extractSummary() only returns the LAST summary tag from raw output,
|
||||
* losing all previous step summaries.
|
||||
*
|
||||
* 3. The UI must prefer feature.summary to display the complete history of all
|
||||
* pipeline steps to the user.
|
||||
*
|
||||
* For non-pipeline features (single execution), both sources contain the same
|
||||
* summary, so the priority doesn't matter. But for pipeline features, using the
|
||||
* wrong source would result in incomplete information display.
|
||||
*/
|
||||
68
apps/server/tests/unit/ui/log-parser-mixed-format.test.ts
Normal file
68
apps/server/tests/unit/ui/log-parser-mixed-format.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
parseAllPhaseSummaries,
|
||||
parsePhaseSummaries,
|
||||
extractPhaseSummary,
|
||||
extractImplementationSummary,
|
||||
isAccumulatedSummary,
|
||||
} from '../../../../ui/src/lib/log-parser.ts';
|
||||
|
||||
describe('log-parser mixed summary format compatibility', () => {
|
||||
const mixedSummary = [
|
||||
'Implemented core auth flow and API wiring.',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### Code Review',
|
||||
'',
|
||||
'Addressed lint warnings and improved error handling.',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### Testing',
|
||||
'',
|
||||
'All tests passing.',
|
||||
].join('\n');
|
||||
|
||||
it('treats leading headerless section as Implementation phase', () => {
|
||||
const phases = parsePhaseSummaries(mixedSummary);
|
||||
|
||||
expect(phases.get('implementation')).toBe('Implemented core auth flow and API wiring.');
|
||||
expect(phases.get('code review')).toBe('Addressed lint warnings and improved error handling.');
|
||||
expect(phases.get('testing')).toBe('All tests passing.');
|
||||
});
|
||||
|
||||
it('returns implementation summary from mixed format', () => {
|
||||
expect(extractImplementationSummary(mixedSummary)).toBe(
|
||||
'Implemented core auth flow and API wiring.'
|
||||
);
|
||||
});
|
||||
|
||||
it('includes Implementation as the first parsed phase entry', () => {
|
||||
const entries = parseAllPhaseSummaries(mixedSummary);
|
||||
|
||||
expect(entries[0]).toMatchObject({
|
||||
phaseName: 'Implementation',
|
||||
content: 'Implemented core auth flow and API wiring.',
|
||||
});
|
||||
expect(entries.map((entry) => entry.phaseName)).toEqual([
|
||||
'Implementation',
|
||||
'Code Review',
|
||||
'Testing',
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts specific phase summaries from mixed format', () => {
|
||||
expect(extractPhaseSummary(mixedSummary, 'Implementation')).toBe(
|
||||
'Implemented core auth flow and API wiring.'
|
||||
);
|
||||
expect(extractPhaseSummary(mixedSummary, 'Code Review')).toBe(
|
||||
'Addressed lint warnings and improved error handling.'
|
||||
);
|
||||
expect(extractPhaseSummary(mixedSummary, 'Testing')).toBe('All tests passing.');
|
||||
});
|
||||
|
||||
it('treats mixed format as accumulated summary', () => {
|
||||
expect(isAccumulatedSummary(mixedSummary)).toBe(true);
|
||||
});
|
||||
});
|
||||
973
apps/server/tests/unit/ui/log-parser-phase-summary.test.ts
Normal file
973
apps/server/tests/unit/ui/log-parser-phase-summary.test.ts
Normal file
@@ -0,0 +1,973 @@
|
||||
/**
|
||||
* Unit tests for log-parser phase summary parsing functions.
|
||||
*
|
||||
* These functions are used to parse accumulated summaries that contain multiple
|
||||
* pipeline step summaries separated by `---` and identified by `### StepName` headers.
|
||||
*
|
||||
* Functions tested:
|
||||
* - parsePhaseSummaries: Parses the entire accumulated summary into a Map
|
||||
* - extractPhaseSummary: Extracts a specific phase's content
|
||||
* - extractImplementationSummary: Extracts implementation phase content (convenience)
|
||||
* - isAccumulatedSummary: Checks if a summary is in accumulated format
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// Mirror the functions from apps/ui/src/lib/log-parser.ts
|
||||
// (We can't import directly because it's a UI file)
|
||||
|
||||
/**
|
||||
* Parses an accumulated summary string into individual phase summaries.
|
||||
*/
|
||||
function parsePhaseSummaries(summary: string | undefined): Map<string, string> {
|
||||
const phaseSummaries = new Map<string, string>();
|
||||
|
||||
if (!summary || !summary.trim()) {
|
||||
return phaseSummaries;
|
||||
}
|
||||
|
||||
// Split by the horizontal rule separator
|
||||
const sections = summary.split(/\n\n---\n\n/);
|
||||
|
||||
for (const section of sections) {
|
||||
// Match the phase header pattern: ### Phase Name
|
||||
const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/);
|
||||
if (headerMatch) {
|
||||
const phaseName = headerMatch[1].trim().toLowerCase();
|
||||
// Extract content after the header (skip the header line and leading newlines)
|
||||
const content = section.substring(headerMatch[0].length).trim();
|
||||
phaseSummaries.set(phaseName, content);
|
||||
}
|
||||
}
|
||||
|
||||
return phaseSummaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a specific phase summary from an accumulated summary string.
|
||||
*/
|
||||
function extractPhaseSummary(summary: string | undefined, phaseName: string): string | null {
|
||||
const phaseSummaries = parsePhaseSummaries(summary);
|
||||
const normalizedPhaseName = phaseName.toLowerCase();
|
||||
return phaseSummaries.get(normalizedPhaseName) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the implementation phase summary from an accumulated summary string.
|
||||
*/
|
||||
function extractImplementationSummary(summary: string | undefined): string | null {
|
||||
if (!summary || !summary.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const phaseSummaries = parsePhaseSummaries(summary);
|
||||
|
||||
// Try exact match first
|
||||
const implementationContent = phaseSummaries.get('implementation');
|
||||
if (implementationContent) {
|
||||
return implementationContent;
|
||||
}
|
||||
|
||||
// Fallback: find any phase containing "implement"
|
||||
for (const [phaseName, content] of phaseSummaries) {
|
||||
if (phaseName.includes('implement')) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
// If no phase summaries found, the summary might not be in accumulated format
|
||||
// (legacy or non-pipeline feature). In this case, return the whole summary
|
||||
// if it looks like a single summary (no phase headers).
|
||||
if (!summary.includes('### ') && !summary.includes('\n---\n')) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a summary string is in the accumulated multi-phase format.
|
||||
*/
|
||||
function isAccumulatedSummary(summary: string | undefined): boolean {
|
||||
if (!summary || !summary.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for the presence of phase headers with separator
|
||||
const hasMultiplePhases =
|
||||
summary.includes('\n\n---\n\n') && summary.match(/###\s+.+/g)?.length > 0;
|
||||
|
||||
return hasMultiplePhases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single phase entry in an accumulated summary.
|
||||
*/
|
||||
interface PhaseSummaryEntry {
|
||||
/** The phase name (e.g., "Implementation", "Testing", "Code Review") */
|
||||
phaseName: string;
|
||||
/** The content of this phase's summary */
|
||||
content: string;
|
||||
/** The original header line (e.g., "### Implementation") */
|
||||
header: string;
|
||||
}
|
||||
|
||||
/** Default phase name used for non-accumulated summaries */
|
||||
const DEFAULT_PHASE_NAME = 'Summary';
|
||||
|
||||
/**
|
||||
* Parses an accumulated summary into individual phase entries.
|
||||
* Returns phases in the order they appear in the summary.
|
||||
*/
|
||||
function parseAllPhaseSummaries(summary: string | undefined): PhaseSummaryEntry[] {
|
||||
const entries: PhaseSummaryEntry[] = [];
|
||||
|
||||
if (!summary || !summary.trim()) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
// Check if this is an accumulated summary (has phase headers)
|
||||
if (!summary.includes('### ')) {
|
||||
// Not an accumulated summary - return as single entry with generic name
|
||||
return [
|
||||
{ phaseName: DEFAULT_PHASE_NAME, content: summary, header: `### ${DEFAULT_PHASE_NAME}` },
|
||||
];
|
||||
}
|
||||
|
||||
// Split by the horizontal rule separator
|
||||
const sections = summary.split(/\n\n---\n\n/);
|
||||
|
||||
for (const section of sections) {
|
||||
// Match the phase header pattern: ### Phase Name
|
||||
const headerMatch = section.match(/^(###\s+)(.+?)(?:\n|$)/);
|
||||
if (headerMatch) {
|
||||
const header = headerMatch[0].trim();
|
||||
const phaseName = headerMatch[2].trim();
|
||||
// Extract content after the header (skip the header line and leading newlines)
|
||||
const content = section.substring(headerMatch[0].length).trim();
|
||||
entries.push({ phaseName, content, header });
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
describe('parsePhaseSummaries', () => {
|
||||
describe('basic parsing', () => {
|
||||
it('should parse single phase summary', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
## Changes Made
|
||||
- Created new module
|
||||
- Added unit tests`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get('implementation')).toBe(
|
||||
'## Changes Made\n- Created new module\n- Added unit tests'
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse multiple phase summaries', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
## Changes Made
|
||||
- Created new module
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
## Test Results
|
||||
- All tests pass`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
|
||||
expect(result.size).toBe(2);
|
||||
expect(result.get('implementation')).toBe('## Changes Made\n- Created new module');
|
||||
expect(result.get('testing')).toBe('## Test Results\n- All tests pass');
|
||||
});
|
||||
|
||||
it('should handle three or more phases', () => {
|
||||
const summary = `### Planning
|
||||
|
||||
Plan created
|
||||
|
||||
---
|
||||
|
||||
### Implementation
|
||||
|
||||
Code written
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Tests pass
|
||||
|
||||
---
|
||||
|
||||
### Refinement
|
||||
|
||||
Code polished`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
|
||||
expect(result.size).toBe(4);
|
||||
expect(result.get('planning')).toBe('Plan created');
|
||||
expect(result.get('implementation')).toBe('Code written');
|
||||
expect(result.get('testing')).toBe('Tests pass');
|
||||
expect(result.get('refinement')).toBe('Code polished');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return empty map for undefined summary', () => {
|
||||
const result = parsePhaseSummaries(undefined);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty map for null summary', () => {
|
||||
const result = parsePhaseSummaries(null as unknown as string);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty map for empty string', () => {
|
||||
const result = parsePhaseSummaries('');
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty map for whitespace-only string', () => {
|
||||
const result = parsePhaseSummaries(' \n\n ');
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle summary without phase headers', () => {
|
||||
const summary = 'Just some regular content without headers';
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle section without header after separator', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Content here
|
||||
|
||||
---
|
||||
|
||||
This section has no header`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get('implementation')).toBe('Content here');
|
||||
});
|
||||
});
|
||||
|
||||
describe('phase name normalization', () => {
|
||||
it('should normalize phase names to lowercase', () => {
|
||||
const summary = `### IMPLEMENTATION
|
||||
|
||||
Content`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.has('implementation')).toBe(true);
|
||||
expect(result.has('IMPLEMENTATION')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle mixed case phase names', () => {
|
||||
const summary = `### Code Review
|
||||
|
||||
Content`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.has('code review')).toBe(true);
|
||||
});
|
||||
|
||||
it('should preserve spaces in multi-word phase names', () => {
|
||||
const summary = `### Code Review
|
||||
|
||||
Content`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.get('code review')).toBe('Content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('content preservation', () => {
|
||||
it('should preserve markdown formatting in content', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
## Heading
|
||||
- **Bold text**
|
||||
- \`code\`
|
||||
\`\`\`typescript
|
||||
const x = 1;
|
||||
\`\`\``;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
const content = result.get('implementation');
|
||||
|
||||
expect(content).toContain('**Bold text**');
|
||||
expect(content).toContain('`code`');
|
||||
expect(content).toContain('```typescript');
|
||||
});
|
||||
|
||||
it('should preserve unicode in content', () => {
|
||||
const summary = `### Testing
|
||||
|
||||
Results: ✅ 42 passed, ❌ 0 failed`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.get('testing')).toContain('✅');
|
||||
expect(result.get('testing')).toContain('❌');
|
||||
});
|
||||
|
||||
it('should preserve tables in content', () => {
|
||||
const summary = `### Testing
|
||||
|
||||
| Test | Result |
|
||||
|------|--------|
|
||||
| Unit | Pass |`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.get('testing')).toContain('| Test | Result |');
|
||||
});
|
||||
|
||||
it('should handle empty phase content', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Content`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.get('implementation')).toBe('');
|
||||
expect(result.get('testing')).toBe('Content');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractPhaseSummary', () => {
|
||||
describe('extraction by phase name', () => {
|
||||
it('should extract specified phase content', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Implementation content
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Testing content`;
|
||||
|
||||
expect(extractPhaseSummary(summary, 'Implementation')).toBe('Implementation content');
|
||||
expect(extractPhaseSummary(summary, 'Testing')).toBe('Testing content');
|
||||
});
|
||||
|
||||
it('should be case-insensitive for phase name', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Content`;
|
||||
|
||||
expect(extractPhaseSummary(summary, 'implementation')).toBe('Content');
|
||||
expect(extractPhaseSummary(summary, 'IMPLEMENTATION')).toBe('Content');
|
||||
expect(extractPhaseSummary(summary, 'ImPlEmEnTaTiOn')).toBe('Content');
|
||||
});
|
||||
|
||||
it('should return null for non-existent phase', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Content`;
|
||||
|
||||
expect(extractPhaseSummary(summary, 'NonExistent')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return null for undefined summary', () => {
|
||||
expect(extractPhaseSummary(undefined, 'Implementation')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty summary', () => {
|
||||
expect(extractPhaseSummary('', 'Implementation')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle whitespace in phase name', () => {
|
||||
const summary = `### Code Review
|
||||
|
||||
Content`;
|
||||
|
||||
expect(extractPhaseSummary(summary, 'Code Review')).toBe('Content');
|
||||
expect(extractPhaseSummary(summary, 'code review')).toBe('Content');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractImplementationSummary', () => {
|
||||
describe('exact match', () => {
|
||||
it('should extract implementation phase by exact name', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
## Changes Made
|
||||
- Created feature
|
||||
- Added tests
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Tests pass`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBe('## Changes Made\n- Created feature\n- Added tests');
|
||||
});
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
const summary = `### IMPLEMENTATION
|
||||
|
||||
Content`;
|
||||
|
||||
expect(extractImplementationSummary(summary)).toBe('Content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('partial match fallback', () => {
|
||||
it('should find phase containing "implement"', () => {
|
||||
const summary = `### Feature Implementation
|
||||
|
||||
Content here`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBe('Content here');
|
||||
});
|
||||
|
||||
it('should find phase containing "implementation"', () => {
|
||||
const summary = `### Implementation Phase
|
||||
|
||||
Content here`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBe('Content here');
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy/non-accumulated summary handling', () => {
|
||||
it('should return full summary if no phase headers present', () => {
|
||||
const summary = `## Changes Made
|
||||
- Created feature
|
||||
- Added tests`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBe(summary);
|
||||
});
|
||||
|
||||
it('should return null if summary has phase headers but no implementation', () => {
|
||||
const summary = `### Testing
|
||||
|
||||
Tests pass
|
||||
|
||||
---
|
||||
|
||||
### Review
|
||||
|
||||
Review complete`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should not return full summary if it contains phase headers', () => {
|
||||
const summary = `### Testing
|
||||
|
||||
Tests pass`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return null for undefined summary', () => {
|
||||
expect(extractImplementationSummary(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty string', () => {
|
||||
expect(extractImplementationSummary('')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for whitespace-only string', () => {
|
||||
expect(extractImplementationSummary(' \n\n ')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAccumulatedSummary', () => {
|
||||
describe('accumulated format detection', () => {
|
||||
it('should return true for accumulated summary with separator and headers', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Content
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Content`;
|
||||
|
||||
expect(isAccumulatedSummary(summary)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for accumulated summary with multiple phases', () => {
|
||||
const summary = `### Phase 1
|
||||
|
||||
Content 1
|
||||
|
||||
---
|
||||
|
||||
### Phase 2
|
||||
|
||||
Content 2
|
||||
|
||||
---
|
||||
|
||||
### Phase 3
|
||||
|
||||
Content 3`;
|
||||
|
||||
expect(isAccumulatedSummary(summary)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for accumulated summary with just one phase and separator', () => {
|
||||
// Even a single phase with a separator suggests it's in accumulated format
|
||||
const summary = `### Implementation
|
||||
|
||||
Content
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
More content`;
|
||||
|
||||
expect(isAccumulatedSummary(summary)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-accumulated format detection', () => {
|
||||
it('should return false for summary without separator', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Just content`;
|
||||
|
||||
expect(isAccumulatedSummary(summary)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for summary with separator but no headers', () => {
|
||||
const summary = `Content
|
||||
|
||||
---
|
||||
|
||||
More content`;
|
||||
|
||||
expect(isAccumulatedSummary(summary)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for simple text summary', () => {
|
||||
const summary = 'Just a simple summary without any special formatting';
|
||||
expect(isAccumulatedSummary(summary)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for markdown summary without phase headers', () => {
|
||||
const summary = `## Changes Made
|
||||
- Created feature
|
||||
- Added tests`;
|
||||
expect(isAccumulatedSummary(summary)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return false for undefined summary', () => {
|
||||
expect(isAccumulatedSummary(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for null summary', () => {
|
||||
expect(isAccumulatedSummary(null as unknown as string)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty string', () => {
|
||||
expect(isAccumulatedSummary('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for whitespace-only string', () => {
|
||||
expect(isAccumulatedSummary(' \n\n ')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration: Full parsing workflow', () => {
|
||||
it('should correctly parse typical server-accumulated pipeline summary', () => {
|
||||
// This simulates what FeatureStateManager.saveFeatureSummary() produces
|
||||
const summary = [
|
||||
'### Implementation',
|
||||
'',
|
||||
'## Changes',
|
||||
'- Added auth module',
|
||||
'- Created user service',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### Code Review',
|
||||
'',
|
||||
'## Review Results',
|
||||
'- Style issues fixed',
|
||||
'- Added error handling',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### Testing',
|
||||
'',
|
||||
'## Test Results',
|
||||
'- 42 tests pass',
|
||||
'- 98% coverage',
|
||||
].join('\n');
|
||||
|
||||
// Verify isAccumulatedSummary
|
||||
expect(isAccumulatedSummary(summary)).toBe(true);
|
||||
|
||||
// Verify parsePhaseSummaries
|
||||
const phases = parsePhaseSummaries(summary);
|
||||
expect(phases.size).toBe(3);
|
||||
expect(phases.get('implementation')).toContain('Added auth module');
|
||||
expect(phases.get('code review')).toContain('Style issues fixed');
|
||||
expect(phases.get('testing')).toContain('42 tests pass');
|
||||
|
||||
// Verify extractPhaseSummary
|
||||
expect(extractPhaseSummary(summary, 'Implementation')).toContain('Added auth module');
|
||||
expect(extractPhaseSummary(summary, 'Code Review')).toContain('Style issues fixed');
|
||||
expect(extractPhaseSummary(summary, 'Testing')).toContain('42 tests pass');
|
||||
|
||||
// Verify extractImplementationSummary
|
||||
expect(extractImplementationSummary(summary)).toContain('Added auth module');
|
||||
});
|
||||
|
||||
it('should handle legacy non-pipeline summary correctly', () => {
|
||||
// Legacy features have simple summaries without accumulation
|
||||
const summary = `## Implementation Complete
|
||||
- Created the feature
|
||||
- All tests pass`;
|
||||
|
||||
// Should NOT be detected as accumulated
|
||||
expect(isAccumulatedSummary(summary)).toBe(false);
|
||||
|
||||
// parsePhaseSummaries should return empty
|
||||
const phases = parsePhaseSummaries(summary);
|
||||
expect(phases.size).toBe(0);
|
||||
|
||||
// extractPhaseSummary should return null
|
||||
expect(extractPhaseSummary(summary, 'Implementation')).toBeNull();
|
||||
|
||||
// extractImplementationSummary should return the full summary (legacy handling)
|
||||
expect(extractImplementationSummary(summary)).toBe(summary);
|
||||
});
|
||||
|
||||
it('should handle single-step pipeline summary', () => {
|
||||
// A single pipeline step still gets the header but no separator
|
||||
const summary = `### Implementation
|
||||
|
||||
## Changes
|
||||
- Created the feature`;
|
||||
|
||||
// Should NOT be detected as accumulated (no separator)
|
||||
expect(isAccumulatedSummary(summary)).toBe(false);
|
||||
|
||||
// parsePhaseSummaries should still extract the single phase
|
||||
const phases = parsePhaseSummaries(summary);
|
||||
expect(phases.size).toBe(1);
|
||||
expect(phases.get('implementation')).toContain('Created the feature');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* KEY ARCHITECTURE NOTES:
|
||||
*
|
||||
* 1. The accumulated summary format uses:
|
||||
* - `### PhaseName` for step headers
|
||||
* - `\n\n---\n\n` as separator between steps
|
||||
*
|
||||
* 2. Phase names are normalized to lowercase in the Map for case-insensitive lookup.
|
||||
*
|
||||
* 3. Legacy summaries (non-pipeline features) don't have phase headers and should
|
||||
* be returned as-is by extractImplementationSummary.
|
||||
*
|
||||
* 4. isAccumulatedSummary() checks for BOTH separator AND phase headers to be
|
||||
* confident that the summary is in the accumulated format.
|
||||
*
|
||||
* 5. The server-side FeatureStateManager.saveFeatureSummary() is responsible for
|
||||
* creating summaries in this accumulated format.
|
||||
*/
|
||||
|
||||
describe('parseAllPhaseSummaries', () => {
|
||||
describe('basic parsing', () => {
|
||||
it('should parse single phase summary into array with one entry', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
## Changes Made
|
||||
- Created new module
|
||||
- Added unit tests`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].phaseName).toBe('Implementation');
|
||||
expect(result[0].content).toBe('## Changes Made\n- Created new module\n- Added unit tests');
|
||||
expect(result[0].header).toBe('### Implementation');
|
||||
});
|
||||
|
||||
it('should parse multiple phase summaries in order', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
## Changes Made
|
||||
- Created new module
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
## Test Results
|
||||
- All tests pass`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
// Verify order is preserved
|
||||
expect(result[0].phaseName).toBe('Implementation');
|
||||
expect(result[0].content).toBe('## Changes Made\n- Created new module');
|
||||
expect(result[1].phaseName).toBe('Testing');
|
||||
expect(result[1].content).toBe('## Test Results\n- All tests pass');
|
||||
});
|
||||
|
||||
it('should parse three or more phases in correct order', () => {
|
||||
const summary = `### Planning
|
||||
|
||||
Plan created
|
||||
|
||||
---
|
||||
|
||||
### Implementation
|
||||
|
||||
Code written
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Tests pass
|
||||
|
||||
---
|
||||
|
||||
### Refinement
|
||||
|
||||
Code polished`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
|
||||
expect(result.length).toBe(4);
|
||||
expect(result[0].phaseName).toBe('Planning');
|
||||
expect(result[1].phaseName).toBe('Implementation');
|
||||
expect(result[2].phaseName).toBe('Testing');
|
||||
expect(result[3].phaseName).toBe('Refinement');
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-accumulated summary handling', () => {
|
||||
it('should return single entry for summary without phase headers', () => {
|
||||
const summary = `## Changes Made
|
||||
- Created feature
|
||||
- Added tests`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].phaseName).toBe('Summary');
|
||||
expect(result[0].content).toBe(summary);
|
||||
});
|
||||
|
||||
it('should return single entry for simple text summary', () => {
|
||||
const summary = 'Just a simple summary without any special formatting';
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].phaseName).toBe('Summary');
|
||||
expect(result[0].content).toBe(summary);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return empty array for undefined summary', () => {
|
||||
const result = parseAllPhaseSummaries(undefined);
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty array for empty string', () => {
|
||||
const result = parseAllPhaseSummaries('');
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return empty array for whitespace-only string', () => {
|
||||
const result = parseAllPhaseSummaries(' \n\n ');
|
||||
expect(result.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle section without header after separator', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Content here
|
||||
|
||||
---
|
||||
|
||||
This section has no header`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].phaseName).toBe('Implementation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('content preservation', () => {
|
||||
it('should preserve markdown formatting in content', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
## Heading
|
||||
- **Bold text**
|
||||
- \`code\`
|
||||
\`\`\`typescript
|
||||
const x = 1;
|
||||
\`\`\``;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
const content = result[0].content;
|
||||
|
||||
expect(content).toContain('**Bold text**');
|
||||
expect(content).toContain('`code`');
|
||||
expect(content).toContain('```typescript');
|
||||
});
|
||||
|
||||
it('should preserve unicode in content', () => {
|
||||
const summary = `### Testing
|
||||
|
||||
Results: ✅ 42 passed, ❌ 0 failed`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
expect(result[0].content).toContain('✅');
|
||||
expect(result[0].content).toContain('❌');
|
||||
});
|
||||
|
||||
it('should preserve tables in content', () => {
|
||||
const summary = `### Testing
|
||||
|
||||
| Test | Result |
|
||||
|------|--------|
|
||||
| Unit | Pass |`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
expect(result[0].content).toContain('| Test | Result |');
|
||||
});
|
||||
|
||||
it('should handle empty phase content', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Content`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0].content).toBe('');
|
||||
expect(result[1].content).toBe('Content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('header preservation', () => {
|
||||
it('should preserve original header text', () => {
|
||||
const summary = `### Code Review
|
||||
|
||||
Content`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
expect(result[0].header).toBe('### Code Review');
|
||||
});
|
||||
|
||||
it('should preserve phase name with original casing', () => {
|
||||
const summary = `### CODE REVIEW
|
||||
|
||||
Content`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
expect(result[0].phaseName).toBe('CODE REVIEW');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chronological order preservation', () => {
|
||||
it('should maintain order: Alpha before Beta before Gamma', () => {
|
||||
const summary = `### Alpha
|
||||
|
||||
First
|
||||
|
||||
---
|
||||
|
||||
### Beta
|
||||
|
||||
Second
|
||||
|
||||
---
|
||||
|
||||
### Gamma
|
||||
|
||||
Third`;
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
const names = result.map((e) => e.phaseName);
|
||||
expect(names).toEqual(['Alpha', 'Beta', 'Gamma']);
|
||||
});
|
||||
|
||||
it('should preserve typical pipeline order', () => {
|
||||
const summary = [
|
||||
'### Implementation',
|
||||
'',
|
||||
'## Changes',
|
||||
'- Added auth module',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### Code Review',
|
||||
'',
|
||||
'## Review Results',
|
||||
'- Style issues fixed',
|
||||
'',
|
||||
'---',
|
||||
'',
|
||||
'### Testing',
|
||||
'',
|
||||
'## Test Results',
|
||||
'- 42 tests pass',
|
||||
].join('\n');
|
||||
|
||||
const result = parseAllPhaseSummaries(summary);
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
expect(result[0].phaseName).toBe('Implementation');
|
||||
expect(result[1].phaseName).toBe('Code Review');
|
||||
expect(result[2].phaseName).toBe('Testing');
|
||||
});
|
||||
});
|
||||
});
|
||||
453
apps/server/tests/unit/ui/log-parser-summary.test.ts
Normal file
453
apps/server/tests/unit/ui/log-parser-summary.test.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Unit tests for the UI's log-parser extractSummary() function.
|
||||
*
|
||||
* These tests document the behavior of extractSummary() which is used as a
|
||||
* CLIENT-SIDE FALLBACK when feature.summary (server-accumulated) is not available.
|
||||
*
|
||||
* IMPORTANT: extractSummary() returns only the LAST <summary> tag from raw output.
|
||||
* For pipeline features with multiple steps, the server-side FeatureStateManager
|
||||
* accumulates all step summaries into feature.summary, which the UI prefers.
|
||||
*
|
||||
* The tests below verify that extractSummary() correctly:
|
||||
* - Returns the LAST summary when multiple exist (mimicking pipeline accumulation)
|
||||
* - Handles various summary formats (<summary> tags, markdown headers)
|
||||
* - Returns null when no summary is found
|
||||
* - Handles edge cases like empty input and malformed tags
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// Recreate the extractSummary logic from apps/ui/src/lib/log-parser.ts
|
||||
// We can't import directly because it's a UI file, so we mirror the logic here
|
||||
|
||||
/**
|
||||
* Cleans up fragmented streaming text by removing spurious newlines
|
||||
*/
|
||||
function cleanFragmentedText(content: string): string {
|
||||
let cleaned = content.replace(/([a-zA-Z])\n+([a-zA-Z])/g, '$1$2');
|
||||
cleaned = cleaned.replace(/<([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '<$1$2>');
|
||||
cleaned = cleaned.replace(/<\/([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '</$1$2>');
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts summary content from raw log output
|
||||
* Returns the LAST summary text if found, or null if no summary exists
|
||||
*/
|
||||
function extractSummary(rawOutput: string): string | null {
|
||||
if (!rawOutput || !rawOutput.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cleanedOutput = cleanFragmentedText(rawOutput);
|
||||
|
||||
const regexesToTry: Array<{
|
||||
regex: RegExp;
|
||||
processor: (m: RegExpMatchArray) => string;
|
||||
}> = [
|
||||
{ regex: /<summary>([\s\S]*?)<\/summary>/gi, processor: (m) => m[1] },
|
||||
{ regex: /^##\s+Summary[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, processor: (m) => m[1] },
|
||||
{
|
||||
regex: /^##\s+(Feature|Changes|Implementation)[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm,
|
||||
processor: (m) => `## ${m[1]}\n${m[2]}`,
|
||||
},
|
||||
{
|
||||
regex: /(^|\n)(All tasks completed[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g,
|
||||
processor: (m) => m[2],
|
||||
},
|
||||
{
|
||||
regex:
|
||||
/(^|\n)((I've|I have) (successfully |now )?(completed|finished|implemented)[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g,
|
||||
processor: (m) => m[2],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { regex, processor } of regexesToTry) {
|
||||
const matches = [...cleanedOutput.matchAll(regex)];
|
||||
if (matches.length > 0) {
|
||||
const lastMatch = matches[matches.length - 1];
|
||||
return cleanFragmentedText(processor(lastMatch)).trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('log-parser extractSummary (UI fallback)', () => {
|
||||
describe('basic summary extraction', () => {
|
||||
it('should extract summary from <summary> tags', () => {
|
||||
const output = `
|
||||
Some agent output...
|
||||
|
||||
<summary>
|
||||
## Changes Made
|
||||
- Fixed the bug in parser.ts
|
||||
- Added error handling
|
||||
</summary>
|
||||
|
||||
More output...
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toBe('## Changes Made\n- Fixed the bug in parser.ts\n- Added error handling');
|
||||
});
|
||||
|
||||
it('should prefer <summary> tags over markdown headers', () => {
|
||||
const output = `
|
||||
## Summary
|
||||
|
||||
Markdown summary here.
|
||||
|
||||
<summary>
|
||||
XML summary here.
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toBe('XML summary here.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple summaries (pipeline accumulation scenario)', () => {
|
||||
it('should return ONLY the LAST summary tag when multiple exist', () => {
|
||||
// This is the key behavior for pipeline features:
|
||||
// extractSummary returns only the LAST, which is why server-side
|
||||
// accumulation is needed for multi-step pipelines
|
||||
const output = `
|
||||
## Step 1: Code Review
|
||||
|
||||
<summary>
|
||||
- Found 3 issues
|
||||
- Approved with changes
|
||||
</summary>
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Testing
|
||||
|
||||
<summary>
|
||||
- All tests pass
|
||||
- Coverage 95%
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toBe('- All tests pass\n- Coverage 95%');
|
||||
expect(result).not.toContain('Code Review');
|
||||
expect(result).not.toContain('Found 3 issues');
|
||||
});
|
||||
|
||||
it('should return ONLY the LAST summary from three pipeline steps', () => {
|
||||
const output = `
|
||||
<summary>Step 1 complete</summary>
|
||||
|
||||
---
|
||||
|
||||
<summary>Step 2 complete</summary>
|
||||
|
||||
---
|
||||
|
||||
<summary>Step 3 complete - all done!</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toBe('Step 3 complete - all done!');
|
||||
expect(result).not.toContain('Step 1');
|
||||
expect(result).not.toContain('Step 2');
|
||||
});
|
||||
|
||||
it('should handle mixed summary formats across pipeline steps', () => {
|
||||
const output = `
|
||||
## Step 1
|
||||
|
||||
<summary>
|
||||
Implementation done
|
||||
</summary>
|
||||
|
||||
---
|
||||
|
||||
## Step 2
|
||||
|
||||
## Summary
|
||||
Review complete
|
||||
|
||||
---
|
||||
|
||||
## Step 3
|
||||
|
||||
<summary>
|
||||
All tests passing
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
// The <summary> tag format takes priority, and returns the LAST match
|
||||
expect(result).toBe('All tests passing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('priority order of summary patterns', () => {
|
||||
it('should try patterns in priority order: <summary> first, then markdown headers', () => {
|
||||
// When both <summary> tags and markdown headers exist,
|
||||
// <summary> tags should take priority
|
||||
const output = `
|
||||
## Summary
|
||||
|
||||
This markdown summary should be ignored.
|
||||
|
||||
<summary>
|
||||
This XML summary should be used.
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toBe('This XML summary should be used.');
|
||||
expect(result).not.toContain('ignored');
|
||||
});
|
||||
|
||||
it('should fall back to Feature/Changes/Implementation headers when no <summary> tag', () => {
|
||||
// Note: The regex for these headers requires content before the header
|
||||
// (^ at start or preceded by newline). Adding some content before.
|
||||
const output = `
|
||||
Agent output here...
|
||||
|
||||
## Feature
|
||||
|
||||
New authentication system with OAuth support.
|
||||
|
||||
## Next
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
// Should find the Feature header and include it in result
|
||||
// Note: Due to regex behavior, it captures content until next ##
|
||||
expect(result).toContain('## Feature');
|
||||
});
|
||||
|
||||
it('should fall back to completion phrases when no structured summary found', () => {
|
||||
const output = `
|
||||
Working on the feature...
|
||||
Making progress...
|
||||
|
||||
All tasks completed successfully. The feature is ready.
|
||||
|
||||
🔧 Tool: Bash
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toContain('All tasks completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should return null for empty string', () => {
|
||||
expect(extractSummary('')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for whitespace-only string', () => {
|
||||
expect(extractSummary(' \n\n ')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when no summary pattern found', () => {
|
||||
expect(extractSummary('Random agent output without any summary patterns')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle malformed <summary> tags gracefully', () => {
|
||||
const output = `
|
||||
<summary>
|
||||
This summary is never closed...
|
||||
`;
|
||||
// Without closing tag, the regex won't match
|
||||
expect(extractSummary(output)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty <summary> tags', () => {
|
||||
const output = `
|
||||
<summary></summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toBe(''); // Empty string is valid
|
||||
});
|
||||
|
||||
it('should handle <summary> tags with only whitespace', () => {
|
||||
const output = `
|
||||
<summary>
|
||||
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toBe(''); // Trimmed to empty string
|
||||
});
|
||||
|
||||
it('should handle summary with markdown code blocks', () => {
|
||||
const output = `
|
||||
<summary>
|
||||
## Changes
|
||||
|
||||
\`\`\`typescript
|
||||
const x = 1;
|
||||
\`\`\`
|
||||
|
||||
Done!
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toContain('```typescript');
|
||||
expect(result).toContain('const x = 1;');
|
||||
});
|
||||
|
||||
it('should handle summary with special characters', () => {
|
||||
const output = `
|
||||
<summary>
|
||||
Fixed bug in parser.ts: "quotes" and 'apostrophes'
|
||||
Special chars: <>&$@#%^*
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toContain('"quotes"');
|
||||
expect(result).toContain('<>&$@#%^*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fragmented streaming text handling', () => {
|
||||
it('should handle fragmented <summary> tags from streaming', () => {
|
||||
// Sometimes streaming providers split text like "<sum\n\nmary>"
|
||||
const output = `
|
||||
<sum
|
||||
|
||||
mary>
|
||||
Fixed the issue
|
||||
</sum
|
||||
|
||||
mary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
// The cleanFragmentedText function should normalize this
|
||||
expect(result).toBe('Fixed the issue');
|
||||
});
|
||||
|
||||
it('should handle fragmented text within summary content', () => {
|
||||
const output = `
|
||||
<summary>
|
||||
Fixed the bug in par
|
||||
ser.ts
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
// cleanFragmentedText should join "par\n\nser" into "parser"
|
||||
expect(result).toBe('Fixed the bug in parser.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('completion phrase detection', () => {
|
||||
it('should extract "All tasks completed" summaries', () => {
|
||||
const output = `
|
||||
Some output...
|
||||
|
||||
All tasks completed successfully. The feature is ready for review.
|
||||
|
||||
🔧 Tool: Bash
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toContain('All tasks completed');
|
||||
});
|
||||
|
||||
it("should extract I've completed summaries", () => {
|
||||
const output = `
|
||||
Working on feature...
|
||||
|
||||
I've successfully implemented the feature with all requirements met.
|
||||
|
||||
🔧 Tool: Read
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toContain("I've successfully implemented");
|
||||
});
|
||||
|
||||
it('should extract "I have finished" summaries', () => {
|
||||
const output = `
|
||||
Implementation phase...
|
||||
|
||||
I have finished the implementation.
|
||||
|
||||
📋 Planning
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toContain('I have finished');
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world pipeline scenarios', () => {
|
||||
it('should handle typical multi-step pipeline output (returns last only)', () => {
|
||||
// This test documents WHY server-side accumulation is essential:
|
||||
// extractSummary only returns the last step's summary
|
||||
const output = `
|
||||
📋 Planning Mode: Full
|
||||
|
||||
🔧 Tool: Read
|
||||
Input: {"file_path": "src/parser.ts"}
|
||||
|
||||
<summary>
|
||||
## Code Review
|
||||
- Analyzed parser.ts
|
||||
- Found potential improvements
|
||||
</summary>
|
||||
|
||||
---
|
||||
|
||||
## Follow-up Session
|
||||
|
||||
🔧 Tool: Edit
|
||||
Input: {"file_path": "src/parser.ts"}
|
||||
|
||||
<summary>
|
||||
## Implementation
|
||||
- Applied suggested improvements
|
||||
- Updated tests
|
||||
</summary>
|
||||
|
||||
---
|
||||
|
||||
## Follow-up Session
|
||||
|
||||
🔧 Tool: Bash
|
||||
Input: {"command": "npm test"}
|
||||
|
||||
<summary>
|
||||
## Testing
|
||||
- All 42 tests pass
|
||||
- No regressions detected
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
// Only the LAST summary is returned
|
||||
expect(result).toBe('## Testing\n- All 42 tests pass\n- No regressions detected');
|
||||
// Earlier summaries are lost
|
||||
expect(result).not.toContain('Code Review');
|
||||
expect(result).not.toContain('Implementation');
|
||||
});
|
||||
|
||||
it('should handle single-step non-pipeline output', () => {
|
||||
// For non-pipeline features, extractSummary works correctly
|
||||
const output = `
|
||||
Working on feature...
|
||||
|
||||
<summary>
|
||||
## Implementation Complete
|
||||
- Created new component
|
||||
- Added unit tests
|
||||
- Updated documentation
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(output);
|
||||
expect(result).toContain('Implementation Complete');
|
||||
expect(result).toContain('Created new component');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* These tests verify the UI fallback behavior for summary extraction.
|
||||
*
|
||||
* KEY INSIGHT: The extractSummary() function returns only the LAST summary,
|
||||
* which is why the server-side FeatureStateManager.saveFeatureSummary() method
|
||||
* accumulates all step summaries into feature.summary.
|
||||
*
|
||||
* The UI's AgentOutputModal component uses this priority:
|
||||
* 1. feature.summary (server-accumulated, contains all steps)
|
||||
* 2. extractSummary(output) (client-side fallback, last summary only)
|
||||
*
|
||||
* For pipeline features, this ensures all step summaries are displayed.
|
||||
*/
|
||||
533
apps/server/tests/unit/ui/phase-summary-parser.test.ts
Normal file
533
apps/server/tests/unit/ui/phase-summary-parser.test.ts
Normal file
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* Unit tests for the UI's log-parser phase summary parsing functions.
|
||||
*
|
||||
* These tests verify the behavior of:
|
||||
* - parsePhaseSummaries(): Parses accumulated summary into individual phases
|
||||
* - extractPhaseSummary(): Extracts a specific phase's summary
|
||||
* - extractImplementationSummary(): Extracts only the implementation phase
|
||||
* - isAccumulatedSummary(): Checks if summary is in accumulated format
|
||||
*
|
||||
* The accumulated summary format uses markdown headers with `###` for phase names
|
||||
* and `---` as separators between phases.
|
||||
*
|
||||
* TODO: These test helper functions are mirrored from apps/ui/src/lib/log-parser.ts
|
||||
* because server-side tests cannot import from the UI module. If the production
|
||||
* implementation changes, these tests may pass while production fails.
|
||||
* Consider adding an integration test that validates the actual UI parsing behavior.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// ============================================================================
|
||||
// MIRRORED FUNCTIONS from apps/ui/src/lib/log-parser.ts
|
||||
// ============================================================================
|
||||
// NOTE: These functions are mirrored from the UI implementation because
|
||||
// server-side tests cannot import from apps/ui/. Keep these in sync with the
|
||||
// production implementation. The UI implementation includes additional
|
||||
// handling for getPhaseSections/leadingImplementationSection for backward
|
||||
// compatibility with mixed formats.
|
||||
|
||||
/**
|
||||
* Parses an accumulated summary string into individual phase summaries.
|
||||
*/
|
||||
function parsePhaseSummaries(summary: string | undefined): Map<string, string> {
|
||||
const phaseSummaries = new Map<string, string>();
|
||||
|
||||
if (!summary || !summary.trim()) {
|
||||
return phaseSummaries;
|
||||
}
|
||||
|
||||
// Split by the horizontal rule separator
|
||||
const sections = summary.split(/\n\n---\n\n/);
|
||||
|
||||
for (const section of sections) {
|
||||
// Match the phase header pattern: ### Phase Name
|
||||
const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/);
|
||||
if (headerMatch) {
|
||||
const phaseName = headerMatch[1].trim().toLowerCase();
|
||||
// Extract content after the header (skip the header line and leading newlines)
|
||||
const content = section.substring(headerMatch[0].length).trim();
|
||||
phaseSummaries.set(phaseName, content);
|
||||
}
|
||||
}
|
||||
|
||||
return phaseSummaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a specific phase summary from an accumulated summary string.
|
||||
*/
|
||||
function extractPhaseSummary(summary: string | undefined, phaseName: string): string | null {
|
||||
const phaseSummaries = parsePhaseSummaries(summary);
|
||||
const normalizedPhaseName = phaseName.toLowerCase();
|
||||
return phaseSummaries.get(normalizedPhaseName) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the implementation phase summary from an accumulated summary string.
|
||||
*/
|
||||
function extractImplementationSummary(summary: string | undefined): string | null {
|
||||
if (!summary || !summary.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const phaseSummaries = parsePhaseSummaries(summary);
|
||||
|
||||
// Try exact match first
|
||||
const implementationContent = phaseSummaries.get('implementation');
|
||||
if (implementationContent) {
|
||||
return implementationContent;
|
||||
}
|
||||
|
||||
// Fallback: find any phase containing "implement"
|
||||
for (const [phaseName, content] of phaseSummaries) {
|
||||
if (phaseName.includes('implement')) {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
// If no phase summaries found, the summary might not be in accumulated format
|
||||
// (legacy or non-pipeline feature). In this case, return the whole summary
|
||||
// if it looks like a single summary (no phase headers).
|
||||
if (!summary.includes('### ') && !summary.includes('\n---\n')) {
|
||||
return summary;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a summary string is in the accumulated multi-phase format.
|
||||
*/
|
||||
function isAccumulatedSummary(summary: string | undefined): boolean {
|
||||
if (!summary || !summary.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for the presence of phase headers with separator
|
||||
const hasMultiplePhases =
|
||||
summary.includes('\n\n---\n\n') && summary.match(/###\s+.+/g)?.length > 0;
|
||||
|
||||
return hasMultiplePhases;
|
||||
}
|
||||
|
||||
describe('phase summary parser', () => {
|
||||
describe('parsePhaseSummaries', () => {
|
||||
it('should parse single phase summary', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Created auth module with login functionality.`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
|
||||
expect(result.size).toBe(1);
|
||||
expect(result.get('implementation')).toBe('Created auth module with login functionality.');
|
||||
});
|
||||
|
||||
it('should parse multiple phase summaries', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Created auth module.
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
All tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Code Review
|
||||
|
||||
Approved with minor suggestions.`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
|
||||
expect(result.size).toBe(3);
|
||||
expect(result.get('implementation')).toBe('Created auth module.');
|
||||
expect(result.get('testing')).toBe('All tests pass.');
|
||||
expect(result.get('code review')).toBe('Approved with minor suggestions.');
|
||||
});
|
||||
|
||||
it('should handle empty input', () => {
|
||||
expect(parsePhaseSummaries('').size).toBe(0);
|
||||
expect(parsePhaseSummaries(undefined).size).toBe(0);
|
||||
expect(parsePhaseSummaries(' \n\n ').size).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle phase names with spaces', () => {
|
||||
const summary = `### Code Review
|
||||
|
||||
Review findings here.`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.get('code review')).toBe('Review findings here.');
|
||||
});
|
||||
|
||||
it('should normalize phase names to lowercase', () => {
|
||||
const summary = `### IMPLEMENTATION
|
||||
|
||||
Content here.`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.get('implementation')).toBe('Content here.');
|
||||
expect(result.get('IMPLEMENTATION')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle content with markdown', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
## Changes Made
|
||||
- Fixed bug in parser.ts
|
||||
- Added error handling
|
||||
|
||||
\`\`\`typescript
|
||||
const x = 1;
|
||||
\`\`\``;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.get('implementation')).toContain('## Changes Made');
|
||||
expect(result.get('implementation')).toContain('```typescript');
|
||||
});
|
||||
|
||||
it('should return empty map for non-accumulated format', () => {
|
||||
// Legacy format without phase headers
|
||||
const summary = `## Summary
|
||||
|
||||
This is a simple summary without phase headers.`;
|
||||
|
||||
const result = parsePhaseSummaries(summary);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractPhaseSummary', () => {
|
||||
it('should extract specific phase by name (case-insensitive)', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Implementation content.
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Testing content.`;
|
||||
|
||||
expect(extractPhaseSummary(summary, 'implementation')).toBe('Implementation content.');
|
||||
expect(extractPhaseSummary(summary, 'IMPLEMENTATION')).toBe('Implementation content.');
|
||||
expect(extractPhaseSummary(summary, 'Implementation')).toBe('Implementation content.');
|
||||
expect(extractPhaseSummary(summary, 'testing')).toBe('Testing content.');
|
||||
});
|
||||
|
||||
it('should return null for non-existent phase', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Content here.`;
|
||||
|
||||
expect(extractPhaseSummary(summary, 'code review')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty input', () => {
|
||||
expect(extractPhaseSummary('', 'implementation')).toBeNull();
|
||||
expect(extractPhaseSummary(undefined, 'implementation')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractImplementationSummary', () => {
|
||||
it('should extract implementation phase from accumulated summary', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Created auth module.
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
All tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Code Review
|
||||
|
||||
Approved.`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBe('Created auth module.');
|
||||
expect(result).not.toContain('Testing');
|
||||
expect(result).not.toContain('Code Review');
|
||||
});
|
||||
|
||||
it('should return implementation phase even when not first', () => {
|
||||
const summary = `### Planning
|
||||
|
||||
Plan created.
|
||||
|
||||
---
|
||||
|
||||
### Implementation
|
||||
|
||||
Implemented the feature.
|
||||
|
||||
---
|
||||
|
||||
### Review
|
||||
|
||||
Reviewed.`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBe('Implemented the feature.');
|
||||
});
|
||||
|
||||
it('should handle phase with "implementation" in name', () => {
|
||||
const summary = `### Feature Implementation
|
||||
|
||||
Built the feature.`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBe('Built the feature.');
|
||||
});
|
||||
|
||||
it('should return full summary for non-accumulated format (legacy)', () => {
|
||||
// Non-pipeline features store summary without phase headers
|
||||
const summary = `## Changes
|
||||
- Fixed bug
|
||||
- Added tests`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBe(summary);
|
||||
});
|
||||
|
||||
it('should return null for empty input', () => {
|
||||
expect(extractImplementationSummary('')).toBeNull();
|
||||
expect(extractImplementationSummary(undefined)).toBeNull();
|
||||
expect(extractImplementationSummary(' \n\n ')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when no implementation phase in accumulated summary', () => {
|
||||
const summary = `### Testing
|
||||
|
||||
Tests written.
|
||||
|
||||
---
|
||||
|
||||
### Code Review
|
||||
|
||||
Approved.`;
|
||||
|
||||
const result = extractImplementationSummary(summary);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAccumulatedSummary', () => {
|
||||
it('should return true for accumulated multi-phase summary', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Content.
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Content.`;
|
||||
|
||||
expect(isAccumulatedSummary(summary)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for single phase summary (no separator)', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Content.`;
|
||||
|
||||
expect(isAccumulatedSummary(summary)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for legacy non-accumulated format', () => {
|
||||
const summary = `## Summary
|
||||
|
||||
This is a simple summary.`;
|
||||
|
||||
expect(isAccumulatedSummary(summary)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for empty input', () => {
|
||||
expect(isAccumulatedSummary('')).toBe(false);
|
||||
expect(isAccumulatedSummary(undefined)).toBe(false);
|
||||
expect(isAccumulatedSummary(' \n\n ')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true even for two phases', () => {
|
||||
const summary = `### Implementation
|
||||
|
||||
Content A.
|
||||
|
||||
---
|
||||
|
||||
### Code Review
|
||||
|
||||
Content B.`;
|
||||
|
||||
expect(isAccumulatedSummary(summary)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('acceptance criteria scenarios', () => {
|
||||
it('AC1: Implementation summary preserved when Testing completes', () => {
|
||||
// Given a task card completes the Implementation phase,
|
||||
// when the Testing phase subsequently completes,
|
||||
// then the Implementation phase summary must remain stored independently
|
||||
const summary = `### Implementation
|
||||
|
||||
- Created auth module
|
||||
- Added user service
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
- 42 tests pass
|
||||
- 98% coverage`;
|
||||
|
||||
const impl = extractImplementationSummary(summary);
|
||||
const testing = extractPhaseSummary(summary, 'testing');
|
||||
|
||||
expect(impl).toBe('- Created auth module\n- Added user service');
|
||||
expect(testing).toBe('- 42 tests pass\n- 98% coverage');
|
||||
expect(impl).not.toContain('Testing');
|
||||
expect(testing).not.toContain('auth module');
|
||||
});
|
||||
|
||||
it('AC4: Implementation Summary tab shows only implementation phase', () => {
|
||||
// Given a task card has completed the Implementation phase
|
||||
// (regardless of how many subsequent phases have run),
|
||||
// when the user opens the "Implementation Summary" tab,
|
||||
// then it must display only the summary produced by the Implementation phase
|
||||
const summary = `### Implementation
|
||||
|
||||
Implementation phase output here.
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Testing phase output here.
|
||||
|
||||
---
|
||||
|
||||
### Code Review
|
||||
|
||||
Code review output here.`;
|
||||
|
||||
const impl = extractImplementationSummary(summary);
|
||||
|
||||
expect(impl).toBe('Implementation phase output here.');
|
||||
expect(impl).not.toContain('Testing');
|
||||
expect(impl).not.toContain('Code Review');
|
||||
});
|
||||
|
||||
it('AC5: Empty state when implementation not started', () => {
|
||||
// Given a task card has not yet started the Implementation phase
|
||||
const summary = `### Planning
|
||||
|
||||
Planning phase complete.`;
|
||||
|
||||
const impl = extractImplementationSummary(summary);
|
||||
|
||||
// Should return null (UI shows "No implementation summary available")
|
||||
expect(impl).toBeNull();
|
||||
});
|
||||
|
||||
it('AC6: Single phase summary displayed correctly', () => {
|
||||
// Given a task card where Implementation was the only completed phase
|
||||
const summary = `### Implementation
|
||||
|
||||
Only implementation was done.`;
|
||||
|
||||
const impl = extractImplementationSummary(summary);
|
||||
|
||||
expect(impl).toBe('Only implementation was done.');
|
||||
});
|
||||
|
||||
it('AC9: Mid-progress shows only completed phases', () => {
|
||||
// Given a task card is mid-progress
|
||||
// (e.g., Implementation and Testing complete, Code Review pending)
|
||||
const summary = `### Implementation
|
||||
|
||||
Implementation done.
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Testing done.`;
|
||||
|
||||
const phases = parsePhaseSummaries(summary);
|
||||
|
||||
expect(phases.size).toBe(2);
|
||||
expect(phases.has('implementation')).toBe(true);
|
||||
expect(phases.has('testing')).toBe(true);
|
||||
expect(phases.has('code review')).toBe(false);
|
||||
});
|
||||
|
||||
it('AC10: All phases in chronological order', () => {
|
||||
// Given all phases of a task card are complete
|
||||
const summary = `### Implementation
|
||||
|
||||
First phase content.
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Second phase content.
|
||||
|
||||
---
|
||||
|
||||
### Code Review
|
||||
|
||||
Third phase content.`;
|
||||
|
||||
// ParsePhaseSummaries should preserve order
|
||||
const phases = parsePhaseSummaries(summary);
|
||||
const phaseNames = [...phases.keys()];
|
||||
|
||||
expect(phaseNames).toEqual(['implementation', 'testing', 'code review']);
|
||||
});
|
||||
|
||||
it('AC17: Retried phase shows only latest', () => {
|
||||
// Given a phase was retried, when viewing the Summary tab,
|
||||
// only one entry for the retried phase must appear (the latest retry's summary)
|
||||
//
|
||||
// Note: The server-side FeatureStateManager overwrites the phase summary
|
||||
// when the same phase runs again, so we only have one entry per phase name.
|
||||
// This test verifies that the parser correctly handles this.
|
||||
const summary = `### Implementation
|
||||
|
||||
First attempt content.
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
First test run.
|
||||
|
||||
---
|
||||
|
||||
### Implementation
|
||||
|
||||
Retry content - fixed issues.
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
Retry - all tests now pass.`;
|
||||
|
||||
const phases = parsePhaseSummaries(summary);
|
||||
|
||||
// The parser will have both entries, but Map keeps last value for same key
|
||||
expect(phases.get('implementation')).toBe('Retry content - fixed issues.');
|
||||
expect(phases.get('testing')).toBe('Retry - all tests now pass.');
|
||||
});
|
||||
});
|
||||
});
|
||||
238
apps/server/tests/unit/ui/summary-auto-scroll.test.ts
Normal file
238
apps/server/tests/unit/ui/summary-auto-scroll.test.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Unit tests for the summary auto-scroll detection logic.
|
||||
*
|
||||
* These tests verify the behavior of the scroll detection function used in
|
||||
* AgentOutputModal to determine if auto-scroll should be enabled.
|
||||
*
|
||||
* The logic mirrors the handleSummaryScroll function in:
|
||||
* apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx
|
||||
*
|
||||
* Auto-scroll behavior:
|
||||
* - When user is at or near the bottom (< 50px from bottom), auto-scroll is enabled
|
||||
* - When user scrolls up to view older content, auto-scroll is disabled
|
||||
* - Scrolling back to bottom re-enables auto-scroll
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
/**
|
||||
* Determines if the scroll position is at the bottom of the container.
|
||||
* This is the core logic from handleSummaryScroll in AgentOutputModal.
|
||||
*
|
||||
* @param scrollTop - Current scroll position from top
|
||||
* @param scrollHeight - Total scrollable height
|
||||
* @param clientHeight - Visible height of the container
|
||||
* @param threshold - Distance from bottom to consider "at bottom" (default: 50px)
|
||||
* @returns true if at bottom, false otherwise
|
||||
*/
|
||||
function isScrollAtBottom(
|
||||
scrollTop: number,
|
||||
scrollHeight: number,
|
||||
clientHeight: number,
|
||||
threshold = 50
|
||||
): boolean {
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||
return distanceFromBottom < threshold;
|
||||
}
|
||||
|
||||
describe('Summary Auto-Scroll Detection Logic', () => {
|
||||
describe('basic scroll position detection', () => {
|
||||
it('should return true when scrolled to exact bottom', () => {
|
||||
// Container: 500px tall, content: 1000px tall
|
||||
// ScrollTop: 500 (scrolled to bottom)
|
||||
const result = isScrollAtBottom(500, 1000, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when near bottom (within threshold)', () => {
|
||||
// 49px from bottom - within 50px threshold
|
||||
const result = isScrollAtBottom(451, 1000, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when exactly at threshold boundary (49px)', () => {
|
||||
// 49px from bottom
|
||||
const result = isScrollAtBottom(451, 1000, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when just outside threshold (51px)', () => {
|
||||
// 51px from bottom - outside 50px threshold
|
||||
const result = isScrollAtBottom(449, 1000, 500);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when scrolled to top', () => {
|
||||
const result = isScrollAtBottom(0, 1000, 500);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when scrolled to middle', () => {
|
||||
const result = isScrollAtBottom(250, 1000, 500);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases with small content', () => {
|
||||
it('should return true when content fits in viewport (no scroll needed)', () => {
|
||||
// Content is smaller than container - no scrolling possible
|
||||
const result = isScrollAtBottom(0, 300, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when content exactly fits viewport', () => {
|
||||
const result = isScrollAtBottom(0, 500, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when content slightly exceeds viewport (within threshold)', () => {
|
||||
// Content: 540px, Viewport: 500px, can scroll 40px
|
||||
// At scroll 0, we're 40px from bottom - within threshold
|
||||
const result = isScrollAtBottom(0, 540, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('large content scenarios', () => {
|
||||
it('should correctly detect bottom in very long content', () => {
|
||||
// Simulate accumulated summary from many pipeline steps
|
||||
// Content: 10000px, Viewport: 500px
|
||||
const result = isScrollAtBottom(9500, 10000, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should correctly detect non-bottom in very long content', () => {
|
||||
// User scrolled up to read earlier summaries
|
||||
const result = isScrollAtBottom(5000, 10000, 500);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect when user scrolls up from bottom', () => {
|
||||
// Started at bottom (scroll: 9500), then scrolled up 100px
|
||||
const result = isScrollAtBottom(9400, 10000, 500);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom threshold values', () => {
|
||||
it('should work with larger threshold (100px)', () => {
|
||||
// 75px from bottom - within 100px threshold
|
||||
const result = isScrollAtBottom(425, 1000, 500, 100);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with smaller threshold (10px)', () => {
|
||||
// 15px from bottom - outside 10px threshold
|
||||
const result = isScrollAtBottom(485, 1000, 500, 10);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should work with zero threshold (exact match only)', () => {
|
||||
// At exact bottom - distanceFromBottom = 0, which is NOT < 0 with strict comparison
|
||||
// This is an edge case: the implementation uses < not <=
|
||||
const result = isScrollAtBottom(500, 1000, 500, 0);
|
||||
expect(result).toBe(false); // 0 < 0 is false
|
||||
|
||||
// 1px from bottom - also fails
|
||||
const result2 = isScrollAtBottom(499, 1000, 500, 0);
|
||||
expect(result2).toBe(false);
|
||||
|
||||
// For exact match with 0 threshold, we need negative distanceFromBottom
|
||||
// which happens when scrollTop > scrollHeight - clientHeight (overscroll)
|
||||
const result3 = isScrollAtBottom(501, 1000, 500, 0);
|
||||
expect(result3).toBe(true); // -1 < 0 is true
|
||||
});
|
||||
});
|
||||
|
||||
describe('pipeline summary scrolling scenarios', () => {
|
||||
it('should enable auto-scroll when new content arrives while at bottom', () => {
|
||||
// User is at bottom viewing step 2 summary
|
||||
// Step 3 summary is added, increasing scrollHeight from 1000 to 1500
|
||||
// ScrollTop stays at 950 (was at bottom), but now user needs to scroll
|
||||
|
||||
// Before new content: isScrollAtBottom(950, 1000, 500) = true
|
||||
// After new content: auto-scroll should kick in to scroll to new bottom
|
||||
|
||||
// Simulating the auto-scroll effect setting scrollTop to new bottom
|
||||
const newScrollTop = 1500 - 500; // scrollHeight - clientHeight
|
||||
const result = isScrollAtBottom(newScrollTop, 1500, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should not auto-scroll when user is reading earlier summaries', () => {
|
||||
// User scrolled up to read step 1 summary while step 3 is added
|
||||
// scrollHeight increases, but scrollTop stays same
|
||||
// User is now further from bottom
|
||||
|
||||
// User was at scroll position 200 (reading early content)
|
||||
// New content increases scrollHeight from 1000 to 1500
|
||||
// Distance from bottom goes from 300 to 800
|
||||
const result = isScrollAtBottom(200, 1500, 500);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should re-enable auto-scroll when user scrolls back to bottom', () => {
|
||||
// User was reading step 1 (scrollTop: 200)
|
||||
// User scrolls back to bottom to see latest content
|
||||
const result = isScrollAtBottom(1450, 1500, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decimal scroll values', () => {
|
||||
it('should handle fractional scroll positions', () => {
|
||||
// Browsers can report fractional scroll values
|
||||
const result = isScrollAtBottom(499.5, 1000, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle fractional scroll heights', () => {
|
||||
const result = isScrollAtBottom(450.7, 1000.3, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('negative and invalid inputs', () => {
|
||||
it('should handle negative scrollTop (bounce scroll)', () => {
|
||||
// iOS can report negative scrollTop during bounce
|
||||
const result = isScrollAtBottom(-10, 1000, 500);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle zero scrollHeight', () => {
|
||||
// Empty content
|
||||
const result = isScrollAtBottom(0, 0, 500);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle zero clientHeight', () => {
|
||||
// Hidden container - distanceFromBottom = 1000 - 0 - 0 = 1000
|
||||
// This is not < threshold, so returns false
|
||||
// This edge case represents a broken/invisible container
|
||||
const result = isScrollAtBottom(0, 1000, 0);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world accumulated summary dimensions', () => {
|
||||
it('should handle typical 3-step pipeline summary dimensions', () => {
|
||||
// Approximate: 3 steps x ~800px each = ~2400px
|
||||
// Viewport: 400px (modal height)
|
||||
const result = isScrollAtBottom(2000, 2400, 400);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle large 10-step pipeline summary dimensions', () => {
|
||||
// Approximate: 10 steps x ~800px each = ~8000px
|
||||
// Viewport: 400px
|
||||
const result = isScrollAtBottom(7600, 8000, 400);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect scroll to top of large summary', () => {
|
||||
// User at top of 10-step summary
|
||||
const result = isScrollAtBottom(0, 8000, 400);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
128
apps/server/tests/unit/ui/summary-normalization.test.ts
Normal file
128
apps/server/tests/unit/ui/summary-normalization.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Unit tests for summary normalization between UI components and parser functions.
|
||||
*
|
||||
* These tests verify that:
|
||||
* - getFirstNonEmptySummary returns string | null
|
||||
* - parseAllPhaseSummaries and isAccumulatedSummary expect string | undefined
|
||||
* - The normalization (summary ?? undefined) correctly converts null to undefined
|
||||
*
|
||||
* This ensures the UI components properly bridge the type gap between:
|
||||
* - getFirstNonEmptySummary (returns string | null)
|
||||
* - parseAllPhaseSummaries (expects string | undefined)
|
||||
* - isAccumulatedSummary (expects string | undefined)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseAllPhaseSummaries, isAccumulatedSummary } from '../../../../ui/src/lib/log-parser.ts';
|
||||
import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts';
|
||||
|
||||
describe('Summary Normalization', () => {
|
||||
describe('getFirstNonEmptySummary', () => {
|
||||
it('should return the first non-empty string', () => {
|
||||
const result = getFirstNonEmptySummary(null, undefined, 'valid summary', 'another');
|
||||
expect(result).toBe('valid summary');
|
||||
});
|
||||
|
||||
it('should return null when all candidates are empty', () => {
|
||||
const result = getFirstNonEmptySummary(null, undefined, '', ' ');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null when no candidates provided', () => {
|
||||
const result = getFirstNonEmptySummary();
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for all null/undefined candidates', () => {
|
||||
const result = getFirstNonEmptySummary(null, undefined, null);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should preserve original string formatting (not trim)', () => {
|
||||
const result = getFirstNonEmptySummary(' summary with spaces ');
|
||||
expect(result).toBe(' summary with spaces ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseAllPhaseSummaries with normalized input', () => {
|
||||
it('should handle null converted to undefined via ?? operator', () => {
|
||||
const summary = getFirstNonEmptySummary(null, undefined);
|
||||
// This is the normalization: summary ?? undefined
|
||||
const normalizedSummary = summary ?? undefined;
|
||||
|
||||
// TypeScript should accept this without error
|
||||
const result = parseAllPhaseSummaries(normalizedSummary);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse accumulated summary when non-null is normalized', () => {
|
||||
const rawSummary =
|
||||
'### Implementation\n\nDid some work\n\n---\n\n### Testing\n\nAll tests pass';
|
||||
const summary = getFirstNonEmptySummary(null, rawSummary);
|
||||
const normalizedSummary = summary ?? undefined;
|
||||
|
||||
const result = parseAllPhaseSummaries(normalizedSummary);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].phaseName).toBe('Implementation');
|
||||
expect(result[1].phaseName).toBe('Testing');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAccumulatedSummary with normalized input', () => {
|
||||
it('should return false for null converted to undefined', () => {
|
||||
const summary = getFirstNonEmptySummary(null, undefined);
|
||||
const normalizedSummary = summary ?? undefined;
|
||||
|
||||
const result = isAccumulatedSummary(normalizedSummary);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true for valid accumulated summary after normalization', () => {
|
||||
const rawSummary =
|
||||
'### Implementation\n\nDid some work\n\n---\n\n### Testing\n\nAll tests pass';
|
||||
const summary = getFirstNonEmptySummary(rawSummary);
|
||||
const normalizedSummary = summary ?? undefined;
|
||||
|
||||
const result = isAccumulatedSummary(normalizedSummary);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for single-phase summary after normalization', () => {
|
||||
const rawSummary = '### Implementation\n\nDid some work';
|
||||
const summary = getFirstNonEmptySummary(rawSummary);
|
||||
const normalizedSummary = summary ?? undefined;
|
||||
|
||||
const result = isAccumulatedSummary(normalizedSummary);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type safety verification', () => {
|
||||
it('should demonstrate that null must be normalized to undefined', () => {
|
||||
// This test documents the type mismatch that requires normalization
|
||||
const summary: string | null = getFirstNonEmptySummary(null);
|
||||
const normalizedSummary: string | undefined = summary ?? undefined;
|
||||
|
||||
// parseAllPhaseSummaries expects string | undefined, not string | null
|
||||
// The normalization converts null -> undefined, which is compatible
|
||||
const result = parseAllPhaseSummaries(normalizedSummary);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should work with the actual usage pattern from components', () => {
|
||||
// Simulates the actual pattern used in summary-dialog.tsx and agent-output-modal.tsx
|
||||
const featureSummary: string | null | undefined = null;
|
||||
const extractedSummary: string | null | undefined = undefined;
|
||||
|
||||
const rawSummary = getFirstNonEmptySummary(featureSummary, extractedSummary);
|
||||
const normalizedSummary = rawSummary ?? undefined;
|
||||
|
||||
// Both parser functions should work with the normalized value
|
||||
const phases = parseAllPhaseSummaries(normalizedSummary);
|
||||
const hasMultiple = isAccumulatedSummary(normalizedSummary);
|
||||
|
||||
expect(phases).toEqual([]);
|
||||
expect(hasMultiple).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseAllPhaseSummaries, isAccumulatedSummary } from '../../../../ui/src/lib/log-parser.ts';
|
||||
import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts';
|
||||
|
||||
/**
|
||||
* Mirrors summary source priority in agent-info-panel.tsx:
|
||||
* freshFeature.summary > feature.summary > summaryProp > agentInfo.summary
|
||||
*/
|
||||
function getCardEffectiveSummary(params: {
|
||||
freshFeatureSummary?: string | null;
|
||||
featureSummary?: string | null;
|
||||
summaryProp?: string | null;
|
||||
agentInfoSummary?: string | null;
|
||||
}): string | undefined | null {
|
||||
return getFirstNonEmptySummary(
|
||||
params.freshFeatureSummary,
|
||||
params.featureSummary,
|
||||
params.summaryProp,
|
||||
params.agentInfoSummary
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors SummaryDialog raw summary selection in summary-dialog.tsx:
|
||||
* summaryProp > feature.summary > agentInfo.summary
|
||||
*/
|
||||
function getDialogRawSummary(params: {
|
||||
summaryProp?: string | null;
|
||||
featureSummary?: string | null;
|
||||
agentInfoSummary?: string | null;
|
||||
}): string | undefined | null {
|
||||
return getFirstNonEmptySummary(
|
||||
params.summaryProp,
|
||||
params.featureSummary,
|
||||
params.agentInfoSummary
|
||||
);
|
||||
}
|
||||
|
||||
describe('Summary Source Flow Integration', () => {
|
||||
it('uses fresh per-feature summary in card and preserves it through summary dialog', () => {
|
||||
const staleListSummary = '## Old summary from stale list cache';
|
||||
const freshAccumulatedSummary = `### Implementation
|
||||
|
||||
Implemented auth + profile flow.
|
||||
|
||||
---
|
||||
|
||||
### Testing
|
||||
|
||||
- Unit tests: 18 passed
|
||||
- Integration tests: 6 passed`;
|
||||
const parsedAgentInfoSummary = 'Fallback summary from parsed agent output';
|
||||
|
||||
const cardEffectiveSummary = getCardEffectiveSummary({
|
||||
freshFeatureSummary: freshAccumulatedSummary,
|
||||
featureSummary: staleListSummary,
|
||||
summaryProp: undefined,
|
||||
agentInfoSummary: parsedAgentInfoSummary,
|
||||
});
|
||||
|
||||
expect(cardEffectiveSummary).toBe(freshAccumulatedSummary);
|
||||
|
||||
const dialogRawSummary = getDialogRawSummary({
|
||||
summaryProp: cardEffectiveSummary,
|
||||
featureSummary: staleListSummary,
|
||||
agentInfoSummary: parsedAgentInfoSummary,
|
||||
});
|
||||
|
||||
expect(dialogRawSummary).toBe(freshAccumulatedSummary);
|
||||
expect(isAccumulatedSummary(dialogRawSummary ?? undefined)).toBe(true);
|
||||
|
||||
const phases = parseAllPhaseSummaries(dialogRawSummary ?? undefined);
|
||||
expect(phases).toHaveLength(2);
|
||||
expect(phases[0]?.phaseName).toBe('Implementation');
|
||||
expect(phases[1]?.phaseName).toBe('Testing');
|
||||
});
|
||||
|
||||
it('falls back in order when fresher sources are absent', () => {
|
||||
const cardEffectiveSummary = getCardEffectiveSummary({
|
||||
freshFeatureSummary: undefined,
|
||||
featureSummary: '',
|
||||
summaryProp: undefined,
|
||||
agentInfoSummary: 'Agent parsed fallback',
|
||||
});
|
||||
|
||||
expect(cardEffectiveSummary).toBe('Agent parsed fallback');
|
||||
|
||||
const dialogRawSummary = getDialogRawSummary({
|
||||
summaryProp: undefined,
|
||||
featureSummary: undefined,
|
||||
agentInfoSummary: cardEffectiveSummary,
|
||||
});
|
||||
|
||||
expect(dialogRawSummary).toBe('Agent parsed fallback');
|
||||
expect(isAccumulatedSummary(dialogRawSummary ?? undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats whitespace-only summaries as empty during fallback selection', () => {
|
||||
const cardEffectiveSummary = getCardEffectiveSummary({
|
||||
freshFeatureSummary: ' \n',
|
||||
featureSummary: '\t',
|
||||
summaryProp: ' ',
|
||||
agentInfoSummary: 'Agent parsed fallback',
|
||||
});
|
||||
|
||||
expect(cardEffectiveSummary).toBe('Agent parsed fallback');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user