Files
automaker/apps/server/tests/unit/lib/file-editor-store-logic.test.ts
gsxdsm 1c0e460dd1 Add orphaned features management routes and UI integration (#819)
* test(copilot): add edge case test for error with code field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Changes from fix/bug-fixes-1-0

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

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

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

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

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

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

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

* chore: Update .gitignore and enhance orphaned feature handling

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

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-27 22:14:41 -08:00

334 lines
10 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
computeIsDirty,
updateTabWithContent as updateTabContent,
markTabAsSaved as markTabSaved,
} from '../../../../ui/src/components/views/file-editor-view/file-editor-dirty-utils.ts';
/**
* Unit tests for the file editor store logic, focusing on the unsaved indicator fix.
*
* The bug was: File unsaved indicators weren't working reliably - editing a file
* and saving it would sometimes leave the dirty indicator (dot) visible.
*
* Root causes:
* 1. Stale closure in handleSave - captured activeTab could have old content
* 2. Editor buffer not synced - CodeMirror might have buffered changes not yet in store
*
* Fix:
* - handleSave now gets fresh state from store using getState()
* - handleSave gets current content from editor via getValue()
* - Content is synced to store before saving if it differs
*
* Since we can't easily test the React/zustand store in node environment,
* we test the pure logic that the store uses for dirty state tracking.
*/
describe('File editor dirty state logic', () => {
describe('updateTabContent', () => {
it('should set isDirty to true when content differs from originalContent', () => {
const tab = {
content: 'original content',
originalContent: 'original content',
isDirty: false,
};
const updated = updateTabContent(tab, 'modified content');
expect(updated.isDirty).toBe(true);
expect(updated.content).toBe('modified content');
expect(updated.originalContent).toBe('original content');
});
it('should set isDirty to false when content matches originalContent', () => {
const tab = {
content: 'original content',
originalContent: 'original content',
isDirty: false,
};
// First modify it
let updated = updateTabContent(tab, 'modified content');
expect(updated.isDirty).toBe(true);
// Now update back to original
updated = updateTabContent(updated, 'original content');
expect(updated.isDirty).toBe(false);
});
it('should handle empty content correctly', () => {
const tab = {
content: '',
originalContent: '',
isDirty: false,
};
const updated = updateTabContent(tab, 'new content');
expect(updated.isDirty).toBe(true);
});
});
describe('markTabSaved', () => {
it('should set isDirty to false and update both content and originalContent', () => {
const tab = {
content: 'original content',
originalContent: 'original content',
isDirty: false,
};
// First modify
let updated = updateTabContent(tab, 'modified content');
expect(updated.isDirty).toBe(true);
// Then save
updated = markTabSaved(updated, 'modified content');
expect(updated.isDirty).toBe(false);
expect(updated.content).toBe('modified content');
expect(updated.originalContent).toBe('modified content');
});
it('should correctly clear dirty state when save is triggered after edit', () => {
// This test simulates the bug scenario:
// 1. User edits file -> isDirty = true
// 2. User saves -> markTabSaved should set isDirty = false
let tab = {
content: 'initial',
originalContent: 'initial',
isDirty: false,
};
// Simulate user editing
tab = updateTabContent(tab, 'initial\nnew line');
// Should be dirty
expect(tab.isDirty).toBe(true);
// Simulate save (with the content that was saved)
tab = markTabSaved(tab, 'initial\nnew line');
// Should NOT be dirty anymore
expect(tab.isDirty).toBe(false);
});
});
describe('race condition handling', () => {
it('should correctly handle updateTabContent after markTabSaved with same content', () => {
// This tests the scenario where:
// 1. CodeMirror has a pending onChange with content "B"
// 2. User presses save when editor shows "B"
// 3. markTabSaved is called with "B"
// 4. CodeMirror's pending onChange fires with "B" (same content)
// Result: isDirty should remain false
let tab = {
content: 'A',
originalContent: 'A',
isDirty: false,
};
// User edits to "B"
tab = updateTabContent(tab, 'B');
// Save with "B"
tab = markTabSaved(tab, 'B');
// Late onChange with same content "B"
tab = updateTabContent(tab, 'B');
expect(tab.isDirty).toBe(false);
expect(tab.content).toBe('B');
});
it('should correctly handle updateTabContent after markTabSaved with different content', () => {
// This tests the scenario where:
// 1. CodeMirror has a pending onChange with content "C"
// 2. User presses save when store has "B"
// 3. markTabSaved is called with "B"
// 4. CodeMirror's pending onChange fires with "C" (different content)
// Result: isDirty should be true (file changed after save)
let tab = {
content: 'A',
originalContent: 'A',
isDirty: false,
};
// User edits to "B"
tab = updateTabContent(tab, 'B');
// Save with "B"
tab = markTabSaved(tab, 'B');
// Late onChange with different content "C"
tab = updateTabContent(tab, 'C');
// File changed after save, so it should be dirty
expect(tab.isDirty).toBe(true);
expect(tab.content).toBe('C');
expect(tab.originalContent).toBe('B');
});
it('should handle rapid edit-save-edit cycle correctly', () => {
// Simulate rapid user actions
let tab = {
content: 'v1',
originalContent: 'v1',
isDirty: false,
};
// Edit 1
tab = updateTabContent(tab, 'v2');
expect(tab.isDirty).toBe(true);
// Save 1
tab = markTabSaved(tab, 'v2');
expect(tab.isDirty).toBe(false);
// Edit 2
tab = updateTabContent(tab, 'v3');
expect(tab.isDirty).toBe(true);
// Save 2
tab = markTabSaved(tab, 'v3');
expect(tab.isDirty).toBe(false);
// Edit 3 (back to v2)
tab = updateTabContent(tab, 'v2');
expect(tab.isDirty).toBe(true);
// Save 3
tab = markTabSaved(tab, 'v2');
expect(tab.isDirty).toBe(false);
});
});
describe('handleSave stale closure fix simulation', () => {
it('demonstrates the fix: using fresh content instead of closure content', () => {
// This test demonstrates why the fix was necessary.
// The old handleSave captured activeTab in closure, which could be stale.
// The fix gets fresh state from getState() and uses editor.getValue().
// Simulate store state
let storeState = {
tabs: [
{
id: 'tab-1',
content: 'A',
originalContent: 'A',
isDirty: false,
},
],
activeTabId: 'tab-1',
};
// Simulate a "stale closure" capturing the tab state
const staleClosureTab = storeState.tabs[0];
// User edits - store state updates
storeState = {
...storeState,
tabs: [
{
id: 'tab-1',
content: 'B',
originalContent: 'A',
isDirty: true,
},
],
};
// OLD BUG: Using stale closure tab would save "A" (old content)
const oldBugSavedContent = staleClosureTab!.content;
expect(oldBugSavedContent).toBe('A'); // Wrong! Should be "B"
// FIX: Using fresh state from getState() gets correct content
const freshTab = storeState.tabs[0];
const fixedSavedContent = freshTab!.content;
expect(fixedSavedContent).toBe('B'); // Correct!
});
it('demonstrates syncing editor content before save', () => {
// This test demonstrates why we need to get content from editor directly.
// The store might have stale content if onChange hasn't fired yet.
// Simulate store state (has old content because onChange hasn't fired)
let storeContent = 'A';
// Editor has newer content (not yet synced to store)
const editorContent = 'B';
// FIX: Use editor content if available, fall back to store content
const contentToSave = editorContent ?? storeContent;
expect(contentToSave).toBe('B'); // Correctly saves editor content
// Simulate syncing to store before save
if (editorContent !== null && editorContent !== storeContent) {
storeContent = editorContent;
}
// Now store is synced
expect(storeContent).toBe('B');
// After save, markTabSaved would set originalContent = savedContent
// and isDirty = false (if no more changes come in)
});
});
describe('edge cases', () => {
it('should handle whitespace-only changes as dirty', () => {
let tab = {
content: 'hello',
originalContent: 'hello',
isDirty: false,
};
tab = updateTabContent(tab, 'hello ');
expect(tab.isDirty).toBe(true);
});
it('should treat CRLF and LF line endings as equivalent (not dirty)', () => {
let tab = {
content: 'line1\nline2',
originalContent: 'line1\nline2',
isDirty: false,
};
// CodeMirror normalizes \r\n to \n internally, so content that only
// differs by line endings should NOT be considered dirty.
tab = updateTabContent(tab, 'line1\r\nline2');
expect(tab.isDirty).toBe(false);
});
it('should handle unicode content correctly', () => {
let tab = {
content: '你好世界',
originalContent: '你好世界',
isDirty: false,
};
tab = updateTabContent(tab, '你好宇宙');
expect(tab.isDirty).toBe(true);
tab = markTabSaved(tab, '你好宇宙');
expect(tab.isDirty).toBe(false);
});
it('should handle very large content efficiently', () => {
// Generate a large string (1MB)
const largeOriginal = 'x'.repeat(1024 * 1024);
const largeModified = largeOriginal + 'y';
let tab = {
content: largeOriginal,
originalContent: largeOriginal,
isDirty: false,
};
tab = updateTabContent(tab, largeModified);
expect(tab.isDirty).toBe(true);
});
});
});