mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
This reverts commit 583c3eb4a6.
This commit is contained in:
@@ -59,10 +59,6 @@ export default defineConfig({
|
|||||||
ALLOWED_ROOT_DIRECTORY: '',
|
ALLOWED_ROOT_DIRECTORY: '',
|
||||||
// Simulate containerized environment to skip sandbox confirmation dialogs
|
// Simulate containerized environment to skip sandbox confirmation dialogs
|
||||||
IS_CONTAINERIZED: 'true',
|
IS_CONTAINERIZED: 'true',
|
||||||
// Increase Node.js memory limit to prevent OOM during tests
|
|
||||||
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--max-old-space-size=4096']
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' '),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ export function HeaderActionsPanelTrigger({
|
|||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className={cn('h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden', className)}
|
className={cn('h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden', className)}
|
||||||
aria-label={isOpen ? 'Close actions menu' : 'Open actions menu'}
|
aria-label={isOpen ? 'Close actions menu' : 'Open actions menu'}
|
||||||
data-testid="header-actions-panel-trigger"
|
|
||||||
>
|
>
|
||||||
{isOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
{isOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -24,10 +24,8 @@ import {
|
|||||||
FilePlus,
|
FilePlus,
|
||||||
FileUp,
|
FileUp,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
ArrowLeft,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { useIsMobile } from '@/hooks/use-media-query';
|
|
||||||
import {
|
import {
|
||||||
useKeyboardShortcuts,
|
useKeyboardShortcuts,
|
||||||
useKeyboardShortcutsConfig,
|
useKeyboardShortcutsConfig,
|
||||||
@@ -44,7 +42,7 @@ import {
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { sanitizeFilename, isMarkdownFilename, isImageFilename } from '@/lib/image-utils';
|
import { sanitizeFilename } from '@/lib/image-utils';
|
||||||
import { Markdown } from '../ui/markdown';
|
import { Markdown } from '../ui/markdown';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -56,16 +54,6 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
|
|
||||||
const logger = createLogger('ContextView');
|
const logger = createLogger('ContextView');
|
||||||
|
|
||||||
// Responsive layout classes
|
|
||||||
const FILE_LIST_BASE_CLASSES = 'border-r border-border flex flex-col overflow-hidden';
|
|
||||||
const FILE_LIST_DESKTOP_CLASSES = 'w-64';
|
|
||||||
const FILE_LIST_EXPANDED_CLASSES = 'flex-1';
|
|
||||||
const FILE_LIST_MOBILE_NO_SELECTION_CLASSES = 'w-full border-r-0';
|
|
||||||
const FILE_LIST_MOBILE_SELECTION_CLASSES = 'hidden';
|
|
||||||
|
|
||||||
const EDITOR_PANEL_BASE_CLASSES = 'flex-1 flex flex-col overflow-hidden';
|
|
||||||
const EDITOR_PANEL_MOBILE_HIDDEN_CLASSES = 'hidden';
|
|
||||||
|
|
||||||
interface ContextFile {
|
interface ContextFile {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'text' | 'image';
|
type: 'text' | 'image';
|
||||||
@@ -115,9 +103,6 @@ export function ContextView() {
|
|||||||
// File input ref for import
|
// File input ref for import
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// Mobile detection
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
// Keyboard shortcuts for this view
|
// Keyboard shortcuts for this view
|
||||||
const contextShortcuts: KeyboardShortcut[] = useMemo(
|
const contextShortcuts: KeyboardShortcut[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -137,6 +122,18 @@ export function ContextView() {
|
|||||||
return `${currentProject.path}/.automaker/context`;
|
return `${currentProject.path}/.automaker/context`;
|
||||||
}, [currentProject]);
|
}, [currentProject]);
|
||||||
|
|
||||||
|
const isMarkdownFile = (filename: string): boolean => {
|
||||||
|
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||||
|
return ext === '.md' || ext === '.markdown';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine if a file is an image based on extension
|
||||||
|
const isImageFile = (filename: string): boolean => {
|
||||||
|
const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp'];
|
||||||
|
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||||
|
return imageExtensions.includes(ext);
|
||||||
|
};
|
||||||
|
|
||||||
// Load context metadata
|
// Load context metadata
|
||||||
const loadMetadata = useCallback(async (): Promise<ContextMetadata> => {
|
const loadMetadata = useCallback(async (): Promise<ContextMetadata> => {
|
||||||
const contextPath = getContextPath();
|
const contextPath = getContextPath();
|
||||||
@@ -198,15 +195,10 @@ export function ContextView() {
|
|||||||
const result = await api.readdir(contextPath);
|
const result = await api.readdir(contextPath);
|
||||||
if (result.success && result.entries) {
|
if (result.success && result.entries) {
|
||||||
const files: ContextFile[] = result.entries
|
const files: ContextFile[] = result.entries
|
||||||
.filter(
|
.filter((entry) => entry.isFile && entry.name !== 'context-metadata.json')
|
||||||
(entry) =>
|
|
||||||
entry.isFile &&
|
|
||||||
entry.name !== 'context-metadata.json' &&
|
|
||||||
(isMarkdownFilename(entry.name) || isImageFilename(entry.name))
|
|
||||||
)
|
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
type: isImageFilename(entry.name) ? 'image' : 'text',
|
type: isImageFile(entry.name) ? 'image' : 'text',
|
||||||
path: `${contextPath}/${entry.name}`,
|
path: `${contextPath}/${entry.name}`,
|
||||||
description: metadata.files[entry.name]?.description,
|
description: metadata.files[entry.name]?.description,
|
||||||
}));
|
}));
|
||||||
@@ -240,10 +232,11 @@ export function ContextView() {
|
|||||||
|
|
||||||
// Select a file
|
// Select a file
|
||||||
const handleSelectFile = (file: ContextFile) => {
|
const handleSelectFile = (file: ContextFile) => {
|
||||||
// Note: Unsaved changes warning could be added here in the future
|
if (hasChanges) {
|
||||||
// For now, silently proceed to avoid disrupting mobile UX flow
|
// Could add a confirmation dialog here
|
||||||
|
}
|
||||||
loadFileContent(file);
|
loadFileContent(file);
|
||||||
setIsPreviewMode(isMarkdownFilename(file.name));
|
setIsPreviewMode(isMarkdownFile(file.name));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save current file
|
// Save current file
|
||||||
@@ -348,7 +341,7 @@ export function ContextView() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
const isImage = isImageFilename(file.name);
|
const isImage = isImageFile(file.name);
|
||||||
|
|
||||||
let filePath: string;
|
let filePath: string;
|
||||||
let fileName: string;
|
let fileName: string;
|
||||||
@@ -589,7 +582,7 @@ export function ContextView() {
|
|||||||
// Update selected file with new name and path
|
// Update selected file with new name and path
|
||||||
const renamedFile: ContextFile = {
|
const renamedFile: ContextFile = {
|
||||||
name: newName,
|
name: newName,
|
||||||
type: isImageFilename(newName) ? 'image' : 'text',
|
type: isImageFile(newName) ? 'image' : 'text',
|
||||||
path: newPath,
|
path: newPath,
|
||||||
content: result.content,
|
content: result.content,
|
||||||
description: metadata.files[newName]?.description,
|
description: metadata.files[newName]?.description,
|
||||||
@@ -797,17 +790,7 @@ export function ContextView() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Left Panel - File List */}
|
{/* Left Panel - File List */}
|
||||||
{/* Mobile: Full width, hidden when file is selected (full-screen editor) */}
|
<div className="w-64 border-r border-border flex flex-col overflow-hidden">
|
||||||
{/* Desktop: Fixed width w-64, expands to fill space when no file selected */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
FILE_LIST_BASE_CLASSES,
|
|
||||||
FILE_LIST_DESKTOP_CLASSES,
|
|
||||||
!selectedFile && FILE_LIST_EXPANDED_CLASSES,
|
|
||||||
isMobile && !selectedFile && FILE_LIST_MOBILE_NO_SELECTION_CLASSES,
|
|
||||||
isMobile && selectedFile && FILE_LIST_MOBILE_SELECTION_CLASSES
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="p-3 border-b border-border">
|
<div className="p-3 border-b border-border">
|
||||||
<h2 className="text-sm font-semibold text-muted-foreground">
|
<h2 className="text-sm font-semibold text-muted-foreground">
|
||||||
Context Files ({contextFiles.length})
|
Context Files ({contextFiles.length})
|
||||||
@@ -898,31 +881,12 @@ export function ContextView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Panel - Editor/Preview */}
|
{/* Right Panel - Editor/Preview */}
|
||||||
{/* Mobile: Hidden when no file selected (file list shows full screen) */}
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
EDITOR_PANEL_BASE_CLASSES,
|
|
||||||
isMobile && !selectedFile && EDITOR_PANEL_MOBILE_HIDDEN_CLASSES
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{selectedFile ? (
|
{selectedFile ? (
|
||||||
<>
|
<>
|
||||||
{/* File toolbar */}
|
{/* File toolbar */}
|
||||||
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
|
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{/* Mobile-only: Back button to return to file list */}
|
|
||||||
{isMobile && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setSelectedFile(null)}
|
|
||||||
className="shrink-0 -ml-1"
|
|
||||||
aria-label="Back"
|
|
||||||
title="Back"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{selectedFile.type === 'image' ? (
|
{selectedFile.type === 'image' ? (
|
||||||
<ImageIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<ImageIcon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
@@ -930,26 +894,23 @@ export function ContextView() {
|
|||||||
)}
|
)}
|
||||||
<span className="text-sm font-medium truncate">{selectedFile.name}</span>
|
<span className="text-sm font-medium truncate">{selectedFile.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn('flex gap-2', isMobile && 'gap-1')}>
|
<div className="flex gap-2">
|
||||||
{/* Mobile: Icon-only buttons with aria-labels for accessibility */}
|
{selectedFile.type === 'text' && isMarkdownFile(selectedFile.name) && (
|
||||||
{selectedFile.type === 'text' && isMarkdownFilename(selectedFile.name) && (
|
|
||||||
<Button
|
<Button
|
||||||
variant={'outline'}
|
variant={'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsPreviewMode(!isPreviewMode)}
|
onClick={() => setIsPreviewMode(!isPreviewMode)}
|
||||||
data-testid="toggle-preview-mode"
|
data-testid="toggle-preview-mode"
|
||||||
aria-label={isPreviewMode ? 'Edit' : 'Preview'}
|
|
||||||
title={isPreviewMode ? 'Edit' : 'Preview'}
|
|
||||||
>
|
>
|
||||||
{isPreviewMode ? (
|
{isPreviewMode ? (
|
||||||
<>
|
<>
|
||||||
<Pencil className="w-4 h-4" />
|
<Pencil className="w-4 h-4 mr-2" />
|
||||||
{!isMobile && <span className="ml-2">Edit</span>}
|
Edit
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4 mr-2" />
|
||||||
{!isMobile && <span className="ml-2">Preview</span>}
|
Preview
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -960,31 +921,20 @@ export function ContextView() {
|
|||||||
onClick={saveFile}
|
onClick={saveFile}
|
||||||
disabled={!hasChanges || isSaving}
|
disabled={!hasChanges || isSaving}
|
||||||
data-testid="save-context-file"
|
data-testid="save-context-file"
|
||||||
aria-label="Save"
|
|
||||||
title="Save"
|
|
||||||
>
|
>
|
||||||
<Save className="w-4 h-4" />
|
<Save className="w-4 h-4 mr-2" />
|
||||||
{!isMobile && (
|
|
||||||
<span className="ml-2">
|
|
||||||
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
|
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* Desktop-only: Delete button (use dropdown on mobile to save space) */}
|
|
||||||
{!isMobile && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsDeleteDialogOpen(true)}
|
onClick={() => setIsDeleteDialogOpen(true)}
|
||||||
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
|
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
|
||||||
data-testid="delete-context-file"
|
data-testid="delete-context-file"
|
||||||
aria-label="Delete"
|
|
||||||
title="Delete"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1122,7 +1072,7 @@ export function ContextView() {
|
|||||||
.filter((f): f is globalThis.File => f !== null);
|
.filter((f): f is globalThis.File => f !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mdFile = files.find((f) => isMarkdownFilename(f.name));
|
const mdFile = files.find((f) => isMarkdownFile(f.name));
|
||||||
if (mdFile) {
|
if (mdFile) {
|
||||||
const content = await mdFile.text();
|
const content = await mdFile.text();
|
||||||
setNewMarkdownContent(content);
|
setNewMarkdownContent(content);
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
FilePlus,
|
FilePlus,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
ArrowLeft,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useIsMobile } from '@/hooks/use-media-query';
|
|
||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -33,7 +31,6 @@ import {
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { isMarkdownFilename } from '@/lib/image-utils';
|
|
||||||
import { Markdown } from '../ui/markdown';
|
import { Markdown } from '../ui/markdown';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -44,16 +41,6 @@ import {
|
|||||||
|
|
||||||
const logger = createLogger('MemoryView');
|
const logger = createLogger('MemoryView');
|
||||||
|
|
||||||
// Responsive layout classes
|
|
||||||
const FILE_LIST_BASE_CLASSES = 'border-r border-border flex flex-col overflow-hidden';
|
|
||||||
const FILE_LIST_DESKTOP_CLASSES = 'w-64';
|
|
||||||
const FILE_LIST_EXPANDED_CLASSES = 'flex-1';
|
|
||||||
const FILE_LIST_MOBILE_NO_SELECTION_CLASSES = 'w-full border-r-0';
|
|
||||||
const FILE_LIST_MOBILE_SELECTION_CLASSES = 'hidden';
|
|
||||||
|
|
||||||
const EDITOR_PANEL_BASE_CLASSES = 'flex-1 flex flex-col overflow-hidden';
|
|
||||||
const EDITOR_PANEL_MOBILE_HIDDEN_CLASSES = 'hidden';
|
|
||||||
|
|
||||||
interface MemoryFile {
|
interface MemoryFile {
|
||||||
name: string;
|
name: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
@@ -81,15 +68,17 @@ export function MemoryView() {
|
|||||||
// Actions panel state (for tablet/mobile)
|
// Actions panel state (for tablet/mobile)
|
||||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||||
|
|
||||||
// Mobile detection
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
// Get memory directory path
|
// Get memory directory path
|
||||||
const getMemoryPath = useCallback(() => {
|
const getMemoryPath = useCallback(() => {
|
||||||
if (!currentProject) return null;
|
if (!currentProject) return null;
|
||||||
return `${currentProject.path}/.automaker/memory`;
|
return `${currentProject.path}/.automaker/memory`;
|
||||||
}, [currentProject]);
|
}, [currentProject]);
|
||||||
|
|
||||||
|
const isMarkdownFile = (filename: string): boolean => {
|
||||||
|
const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
|
||||||
|
return ext === '.md' || ext === '.markdown';
|
||||||
|
};
|
||||||
|
|
||||||
// Load memory files
|
// Load memory files
|
||||||
const loadMemoryFiles = useCallback(async () => {
|
const loadMemoryFiles = useCallback(async () => {
|
||||||
const memoryPath = getMemoryPath();
|
const memoryPath = getMemoryPath();
|
||||||
@@ -106,7 +95,7 @@ export function MemoryView() {
|
|||||||
const result = await api.readdir(memoryPath);
|
const result = await api.readdir(memoryPath);
|
||||||
if (result.success && result.entries) {
|
if (result.success && result.entries) {
|
||||||
const files: MemoryFile[] = result.entries
|
const files: MemoryFile[] = result.entries
|
||||||
.filter((entry) => entry.isFile && isMarkdownFilename(entry.name))
|
.filter((entry) => entry.isFile && isMarkdownFile(entry.name))
|
||||||
.map((entry) => ({
|
.map((entry) => ({
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
path: `${memoryPath}/${entry.name}`,
|
path: `${memoryPath}/${entry.name}`,
|
||||||
@@ -141,8 +130,9 @@ export function MemoryView() {
|
|||||||
|
|
||||||
// Select a file
|
// Select a file
|
||||||
const handleSelectFile = (file: MemoryFile) => {
|
const handleSelectFile = (file: MemoryFile) => {
|
||||||
// Note: Unsaved changes warning could be added here in the future
|
if (hasChanges) {
|
||||||
// For now, silently proceed to avoid disrupting mobile UX flow
|
// Could add a confirmation dialog here
|
||||||
|
}
|
||||||
loadFileContent(file);
|
loadFileContent(file);
|
||||||
setIsPreviewMode(true);
|
setIsPreviewMode(true);
|
||||||
};
|
};
|
||||||
@@ -391,17 +381,7 @@ export function MemoryView() {
|
|||||||
{/* Main content area with file list and editor */}
|
{/* Main content area with file list and editor */}
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<div className="flex-1 flex overflow-hidden">
|
||||||
{/* Left Panel - File List */}
|
{/* Left Panel - File List */}
|
||||||
{/* Mobile: Full width, hidden when file is selected (full-screen editor) */}
|
<div className="w-64 border-r border-border flex flex-col overflow-hidden">
|
||||||
{/* Desktop: Fixed width w-64, expands to fill space when no file selected */}
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
FILE_LIST_BASE_CLASSES,
|
|
||||||
FILE_LIST_DESKTOP_CLASSES,
|
|
||||||
!selectedFile && FILE_LIST_EXPANDED_CLASSES,
|
|
||||||
isMobile && !selectedFile && FILE_LIST_MOBILE_NO_SELECTION_CLASSES,
|
|
||||||
isMobile && selectedFile && FILE_LIST_MOBILE_SELECTION_CLASSES
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="p-3 border-b border-border">
|
<div className="p-3 border-b border-border">
|
||||||
<h2 className="text-sm font-semibold text-muted-foreground">
|
<h2 className="text-sm font-semibold text-muted-foreground">
|
||||||
Memory Files ({memoryFiles.length})
|
Memory Files ({memoryFiles.length})
|
||||||
@@ -475,53 +455,31 @@ export function MemoryView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Panel - Editor/Preview */}
|
{/* Right Panel - Editor/Preview */}
|
||||||
{/* Mobile: Hidden when no file selected (file list shows full screen) */}
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
EDITOR_PANEL_BASE_CLASSES,
|
|
||||||
isMobile && !selectedFile && EDITOR_PANEL_MOBILE_HIDDEN_CLASSES
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{selectedFile ? (
|
{selectedFile ? (
|
||||||
<>
|
<>
|
||||||
{/* File toolbar */}
|
{/* File toolbar */}
|
||||||
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
|
<div className="flex items-center justify-between p-3 border-b border-border bg-card">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{/* Mobile-only: Back button to return to file list */}
|
|
||||||
{isMobile && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setSelectedFile(null)}
|
|
||||||
className="shrink-0 -ml-1"
|
|
||||||
aria-label="Back"
|
|
||||||
title="Back"
|
|
||||||
>
|
|
||||||
<ArrowLeft className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||||
<span className="text-sm font-medium truncate">{selectedFile.name}</span>
|
<span className="text-sm font-medium truncate">{selectedFile.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={cn('flex gap-2', isMobile && 'gap-1')}>
|
<div className="flex gap-2">
|
||||||
{/* Mobile: Icon-only buttons with aria-labels for accessibility */}
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsPreviewMode(!isPreviewMode)}
|
onClick={() => setIsPreviewMode(!isPreviewMode)}
|
||||||
data-testid="toggle-preview-mode"
|
data-testid="toggle-preview-mode"
|
||||||
aria-label={isPreviewMode ? 'Edit' : 'Preview'}
|
|
||||||
title={isPreviewMode ? 'Edit' : 'Preview'}
|
|
||||||
>
|
>
|
||||||
{isPreviewMode ? (
|
{isPreviewMode ? (
|
||||||
<>
|
<>
|
||||||
<Pencil className="w-4 h-4" />
|
<Pencil className="w-4 h-4 mr-2" />
|
||||||
{!isMobile && <span className="ml-2">Edit</span>}
|
Edit
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4 mr-2" />
|
||||||
{!isMobile && <span className="ml-2">Preview</span>}
|
Preview
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -530,30 +488,19 @@ export function MemoryView() {
|
|||||||
onClick={saveFile}
|
onClick={saveFile}
|
||||||
disabled={!hasChanges || isSaving}
|
disabled={!hasChanges || isSaving}
|
||||||
data-testid="save-memory-file"
|
data-testid="save-memory-file"
|
||||||
aria-label="Save"
|
|
||||||
title="Save"
|
|
||||||
>
|
>
|
||||||
<Save className="w-4 h-4" />
|
<Save className="w-4 h-4 mr-2" />
|
||||||
{!isMobile && (
|
|
||||||
<span className="ml-2">
|
|
||||||
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
|
{isSaving ? 'Saving...' : hasChanges ? 'Save' : 'Saved'}
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
{/* Desktop-only: Delete button (use dropdown on mobile to save space) */}
|
|
||||||
{!isMobile && (
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setIsDeleteDialogOpen(true)}
|
onClick={() => setIsDeleteDialogOpen(true)}
|
||||||
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
|
className="text-red-500 hover:text-red-400 hover:border-red-500/50"
|
||||||
data-testid="delete-memory-file"
|
data-testid="delete-memory-file"
|
||||||
aria-label="Delete"
|
|
||||||
title="Delete"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,6 @@ export const ACCEPTED_TEXT_TYPES = ['text/plain', 'text/markdown', 'text/x-markd
|
|||||||
// File extensions for text files (used for validation when MIME type is unreliable)
|
// File extensions for text files (used for validation when MIME type is unreliable)
|
||||||
export const ACCEPTED_TEXT_EXTENSIONS = ['.txt', '.md'];
|
export const ACCEPTED_TEXT_EXTENSIONS = ['.txt', '.md'];
|
||||||
|
|
||||||
// File extensions for markdown files
|
|
||||||
export const MARKDOWN_EXTENSIONS = ['.md', '.markdown'];
|
|
||||||
|
|
||||||
// File extensions for image files (used for display filtering)
|
|
||||||
export const IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp'];
|
|
||||||
|
|
||||||
// Default max file size (10MB)
|
// Default max file size (10MB)
|
||||||
export const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;
|
export const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||||
|
|
||||||
@@ -240,29 +234,3 @@ export function getTextFileMimeType(filename: string): string {
|
|||||||
}
|
}
|
||||||
return 'text/plain';
|
return 'text/plain';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a filename has a markdown extension
|
|
||||||
*
|
|
||||||
* @param filename - The filename to check
|
|
||||||
* @returns True if the filename has a .md or .markdown extension
|
|
||||||
*/
|
|
||||||
export function isMarkdownFilename(filename: string): boolean {
|
|
||||||
const dotIndex = filename.lastIndexOf('.');
|
|
||||||
if (dotIndex < 0) return false;
|
|
||||||
const ext = filename.toLowerCase().substring(dotIndex);
|
|
||||||
return MARKDOWN_EXTENSIONS.includes(ext);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a filename has an image extension
|
|
||||||
*
|
|
||||||
* @param filename - The filename to check
|
|
||||||
* @returns True if the filename has an image extension
|
|
||||||
*/
|
|
||||||
export function isImageFilename(filename: string): boolean {
|
|
||||||
const dotIndex = filename.lastIndexOf('.');
|
|
||||||
if (dotIndex < 0) return false;
|
|
||||||
const ext = filename.toLowerCase().substring(dotIndex);
|
|
||||||
return IMAGE_EXTENSIONS.includes(ext);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
/**
|
|
||||||
* Desktop Context View E2E Tests
|
|
||||||
*
|
|
||||||
* Tests for desktop behavior in the context view:
|
|
||||||
* - File list and editor visible side-by-side
|
|
||||||
* - Back button is NOT visible on desktop
|
|
||||||
* - Toolbar buttons show both icon and text
|
|
||||||
* - Delete button is visible in toolbar (not hidden like on mobile)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import {
|
|
||||||
resetContextDirectory,
|
|
||||||
setupProjectWithFixture,
|
|
||||||
getFixturePath,
|
|
||||||
navigateToContext,
|
|
||||||
waitForContextFile,
|
|
||||||
selectContextFile,
|
|
||||||
waitForFileContentToLoad,
|
|
||||||
clickElement,
|
|
||||||
fillInput,
|
|
||||||
waitForNetworkIdle,
|
|
||||||
authenticateForTests,
|
|
||||||
waitForElementHidden,
|
|
||||||
} from '../utils';
|
|
||||||
|
|
||||||
// Use desktop viewport for desktop tests
|
|
||||||
test.use({ viewport: { width: 1280, height: 720 } });
|
|
||||||
|
|
||||||
test.describe('Desktop Context View', () => {
|
|
||||||
test.beforeEach(async () => {
|
|
||||||
resetContextDirectory();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterEach(async () => {
|
|
||||||
resetContextDirectory();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show file list and editor side-by-side on desktop', async ({ page }) => {
|
|
||||||
const fileName = 'desktop-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// Create a test file
|
|
||||||
await clickElement(page, 'create-markdown-button');
|
|
||||||
await page.waitForSelector('[data-testid="create-markdown-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-markdown-name', fileName);
|
|
||||||
await fillInput(
|
|
||||||
page,
|
|
||||||
'new-markdown-content',
|
|
||||||
'# Desktop Test\n\nThis tests desktop view behavior'
|
|
||||||
);
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-markdown');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-markdown-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForContextFile(page, fileName);
|
|
||||||
|
|
||||||
// Select the file
|
|
||||||
await selectContextFile(page, fileName);
|
|
||||||
await waitForFileContentToLoad(page);
|
|
||||||
|
|
||||||
// On desktop, file list should be visible after selection
|
|
||||||
const fileList = page.locator('[data-testid="context-file-list"]');
|
|
||||||
await expect(fileList).toBeVisible();
|
|
||||||
|
|
||||||
// Editor panel should also be visible
|
|
||||||
const editor = page.locator('[data-testid="context-editor"], [data-testid="markdown-preview"]');
|
|
||||||
await expect(editor).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should NOT show back button in editor toolbar on desktop', async ({ page }) => {
|
|
||||||
const fileName = 'no-back-button-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// Create a test file
|
|
||||||
await clickElement(page, 'create-markdown-button');
|
|
||||||
await page.waitForSelector('[data-testid="create-markdown-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-markdown-name', fileName);
|
|
||||||
await fillInput(page, 'new-markdown-content', '# No Back Button Test');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-markdown');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-markdown-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForContextFile(page, fileName);
|
|
||||||
|
|
||||||
// Select the file
|
|
||||||
await selectContextFile(page, fileName);
|
|
||||||
await waitForFileContentToLoad(page);
|
|
||||||
|
|
||||||
// Back button should NOT be visible on desktop
|
|
||||||
const backButton = page.locator('button[aria-label="Back"]');
|
|
||||||
await expect(backButton).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show buttons with text labels on desktop', async ({ page }) => {
|
|
||||||
const fileName = 'text-labels-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// Create a test file
|
|
||||||
await clickElement(page, 'create-markdown-button');
|
|
||||||
await page.waitForSelector('[data-testid="create-markdown-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-markdown-name', fileName);
|
|
||||||
await fillInput(
|
|
||||||
page,
|
|
||||||
'new-markdown-content',
|
|
||||||
'# Text Labels Test\n\nTesting button text labels on desktop'
|
|
||||||
);
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-markdown');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-markdown-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForContextFile(page, fileName);
|
|
||||||
|
|
||||||
// Select the file
|
|
||||||
await selectContextFile(page, fileName);
|
|
||||||
await waitForFileContentToLoad(page);
|
|
||||||
|
|
||||||
// Get the toggle preview mode button
|
|
||||||
const toggleButton = page.locator('[data-testid="toggle-preview-mode"]');
|
|
||||||
await expect(toggleButton).toBeVisible();
|
|
||||||
|
|
||||||
// Button should have text label on desktop
|
|
||||||
const buttonText = await toggleButton.textContent();
|
|
||||||
// On desktop, button should have visible text (Edit or Preview)
|
|
||||||
expect(buttonText?.trim()).not.toBe('');
|
|
||||||
expect(buttonText?.toLowerCase()).toMatch(/(edit|preview)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show delete button in toolbar on desktop', async ({ page }) => {
|
|
||||||
const fileName = 'delete-button-desktop-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// Create a test file
|
|
||||||
await clickElement(page, 'create-markdown-button');
|
|
||||||
await page.waitForSelector('[data-testid="create-markdown-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-markdown-name', fileName);
|
|
||||||
await fillInput(page, 'new-markdown-content', '# Delete Button Desktop Test');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-markdown');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-markdown-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForContextFile(page, fileName);
|
|
||||||
|
|
||||||
// Select the file
|
|
||||||
await selectContextFile(page, fileName);
|
|
||||||
await waitForFileContentToLoad(page);
|
|
||||||
|
|
||||||
// Delete button in toolbar should be visible on desktop
|
|
||||||
const deleteButton = page.locator('[data-testid="delete-context-file"]');
|
|
||||||
await expect(deleteButton).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show file list at fixed width on desktop when file is selected', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const fileName = 'fixed-width-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// Create a test file
|
|
||||||
await clickElement(page, 'create-markdown-button');
|
|
||||||
await page.waitForSelector('[data-testid="create-markdown-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-markdown-name', fileName);
|
|
||||||
await fillInput(page, 'new-markdown-content', '# Fixed Width Test');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-markdown');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-markdown-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForContextFile(page, fileName);
|
|
||||||
|
|
||||||
// Select the file
|
|
||||||
await selectContextFile(page, fileName);
|
|
||||||
await waitForFileContentToLoad(page);
|
|
||||||
|
|
||||||
// File list should be visible
|
|
||||||
const fileList = page.locator('[data-testid="context-file-list"]');
|
|
||||||
await expect(fileList).toBeVisible();
|
|
||||||
|
|
||||||
// On desktop with file selected, the file list should be at fixed width (w-64 = 256px)
|
|
||||||
const fileListBox = await fileList.boundingBox();
|
|
||||||
expect(fileListBox).not.toBeNull();
|
|
||||||
|
|
||||||
if (fileListBox) {
|
|
||||||
// Desktop file list is w-64 = 256px, allow some tolerance for borders
|
|
||||||
expect(fileListBox.width).toBeLessThanOrEqual(300);
|
|
||||||
expect(fileListBox.width).toBeGreaterThanOrEqual(200);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show action buttons inline in header on desktop', async ({ page }) => {
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// On desktop, inline buttons should be visible
|
|
||||||
const createButton = page.locator('[data-testid="create-markdown-button"]');
|
|
||||||
await expect(createButton).toBeVisible();
|
|
||||||
|
|
||||||
const importButton = page.locator('[data-testid="import-file-button"]');
|
|
||||||
await expect(importButton).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
/**
|
|
||||||
* Context View File Extension Edge Cases E2E Tests
|
|
||||||
*
|
|
||||||
* Tests for file extension handling in the context view:
|
|
||||||
* - Files with valid markdown extensions (.md, .markdown)
|
|
||||||
* - Files without extensions (edge case for isMarkdownFile/isImageFile)
|
|
||||||
* - Image files with various extensions
|
|
||||||
* - Files with multiple dots in name
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import {
|
|
||||||
resetContextDirectory,
|
|
||||||
setupProjectWithFixture,
|
|
||||||
getFixturePath,
|
|
||||||
navigateToContext,
|
|
||||||
waitForContextFile,
|
|
||||||
selectContextFile,
|
|
||||||
waitForFileContentToLoad,
|
|
||||||
clickElement,
|
|
||||||
fillInput,
|
|
||||||
waitForNetworkIdle,
|
|
||||||
authenticateForTests,
|
|
||||||
waitForElementHidden,
|
|
||||||
createContextFileOnDisk,
|
|
||||||
} from '../utils';
|
|
||||||
|
|
||||||
// Use desktop viewport for these tests
|
|
||||||
test.use({ viewport: { width: 1280, height: 720 } });
|
|
||||||
|
|
||||||
test.describe('Context View File Extension Edge Cases', () => {
|
|
||||||
test.beforeEach(async () => {
|
|
||||||
resetContextDirectory();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterEach(async () => {
|
|
||||||
resetContextDirectory();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle file with .md extension', async ({ page }) => {
|
|
||||||
const fileName = 'standard-file.md';
|
|
||||||
const content = '# Standard Markdown';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// Create file via API
|
|
||||||
createContextFileOnDisk(fileName, content);
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
|
|
||||||
// Refresh to load the file
|
|
||||||
await page.reload();
|
|
||||||
await waitForContextFile(page, fileName);
|
|
||||||
|
|
||||||
// Select and verify it opens as markdown
|
|
||||||
await selectContextFile(page, fileName);
|
|
||||||
await waitForFileContentToLoad(page);
|
|
||||||
|
|
||||||
// Should show markdown preview
|
|
||||||
const markdownPreview = page.locator('[data-testid="markdown-preview"]');
|
|
||||||
await expect(markdownPreview).toBeVisible();
|
|
||||||
|
|
||||||
// Verify content rendered
|
|
||||||
const h1 = markdownPreview.locator('h1');
|
|
||||||
await expect(h1).toHaveText('Standard Markdown');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle file with .markdown extension', async ({ page }) => {
|
|
||||||
const fileName = 'extended-extension.markdown';
|
|
||||||
const content = '# Extended Extension Test';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// Create file via API
|
|
||||||
createContextFileOnDisk(fileName, content);
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
|
|
||||||
// Refresh to load the file
|
|
||||||
await page.reload();
|
|
||||||
await waitForContextFile(page, fileName);
|
|
||||||
|
|
||||||
// Select and verify
|
|
||||||
await selectContextFile(page, fileName);
|
|
||||||
await waitForFileContentToLoad(page);
|
|
||||||
|
|
||||||
const markdownPreview = page.locator('[data-testid="markdown-preview"]');
|
|
||||||
await expect(markdownPreview).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle file with multiple dots in name', async ({ page }) => {
|
|
||||||
const fileName = 'my.detailed.notes.md';
|
|
||||||
const content = '# Multiple Dots Test';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// Create file via API
|
|
||||||
createContextFileOnDisk(fileName, content);
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
|
|
||||||
// Refresh to load the file
|
|
||||||
await page.reload();
|
|
||||||
await waitForContextFile(page, fileName);
|
|
||||||
|
|
||||||
// Select and verify - should still recognize as markdown
|
|
||||||
await selectContextFile(page, fileName);
|
|
||||||
await waitForFileContentToLoad(page);
|
|
||||||
|
|
||||||
const markdownPreview = page.locator('[data-testid="markdown-preview"]');
|
|
||||||
await expect(markdownPreview).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should NOT show file without extension in file list', async ({ page }) => {
|
|
||||||
const fileName = 'README';
|
|
||||||
const content = '# File Without Extension';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// Create file via API (without extension)
|
|
||||||
createContextFileOnDisk(fileName, content);
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
|
|
||||||
// Refresh to load the file
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Wait a moment for files to load
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
// File should NOT appear in list because isMarkdownFile returns false for no extension
|
|
||||||
const fileButton = page.locator(`[data-testid="context-file-${fileName}"]`);
|
|
||||||
await expect(fileButton).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should NOT create file without .md extension via UI', async ({ page }) => {
|
|
||||||
const fileName = 'NOTES';
|
|
||||||
const content = '# Notes without extension';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// Create file via UI without extension
|
|
||||||
await clickElement(page, 'create-markdown-button');
|
|
||||||
await page.waitForSelector('[data-testid="create-markdown-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-markdown-name', fileName);
|
|
||||||
await fillInput(page, 'new-markdown-content', content);
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-markdown');
|
|
||||||
await waitForElementHidden(page, 'create-markdown-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
|
|
||||||
// File should NOT appear in list because UI enforces .md extension
|
|
||||||
// (The UI may add .md automatically or show validation error)
|
|
||||||
const fileButton = page.locator(`[data-testid="context-file-${fileName}"]`);
|
|
||||||
await expect(fileButton)
|
|
||||||
.not.toBeVisible({ timeout: 3000 })
|
|
||||||
.catch(() => {
|
|
||||||
// It's OK if it doesn't appear - that's expected behavior
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle uppercase extensions', async ({ page }) => {
|
|
||||||
const fileName = 'uppercase.MD';
|
|
||||||
const content = '# Uppercase Extension';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// Create file via API with uppercase extension
|
|
||||||
createContextFileOnDisk(fileName, content);
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
|
|
||||||
// Refresh to load the file
|
|
||||||
await page.reload();
|
|
||||||
await waitForContextFile(page, fileName);
|
|
||||||
|
|
||||||
// Select and verify - should recognize .MD as markdown (case-insensitive)
|
|
||||||
await selectContextFile(page, fileName);
|
|
||||||
await waitForFileContentToLoad(page);
|
|
||||||
|
|
||||||
const markdownPreview = page.locator('[data-testid="markdown-preview"]');
|
|
||||||
await expect(markdownPreview).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mobile Context View Operations E2E Tests
|
|
||||||
*
|
|
||||||
* Tests for file operations on mobile in the context view:
|
|
||||||
* - Deleting files via dropdown menu on mobile
|
|
||||||
* - Creating files via mobile actions panel
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect, devices } from '@playwright/test';
|
|
||||||
import {
|
|
||||||
resetContextDirectory,
|
|
||||||
setupProjectWithFixture,
|
|
||||||
getFixturePath,
|
|
||||||
navigateToContext,
|
|
||||||
waitForContextFile,
|
|
||||||
clickElement,
|
|
||||||
fillInput,
|
|
||||||
waitForNetworkIdle,
|
|
||||||
authenticateForTests,
|
|
||||||
contextFileExistsOnDisk,
|
|
||||||
waitForElementHidden,
|
|
||||||
} from '../utils';
|
|
||||||
|
|
||||||
// Use mobile viewport for mobile tests in Chromium CI
|
|
||||||
test.use({ ...devices['Pixel 5'] });
|
|
||||||
|
|
||||||
test.describe('Mobile Context View Operations', () => {
|
|
||||||
test.beforeEach(async () => {
|
|
||||||
resetContextDirectory();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterEach(async () => {
|
|
||||||
resetContextDirectory();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should create a file via mobile actions panel', async ({ page }) => {
|
|
||||||
const fileName = 'mobile-created.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// Create a test file via mobile actions panel
|
|
||||||
await clickElement(page, 'header-actions-panel-trigger');
|
|
||||||
await clickElement(page, 'create-markdown-button-mobile');
|
|
||||||
await page.waitForSelector('[data-testid="create-markdown-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-markdown-name', fileName);
|
|
||||||
await fillInput(page, 'new-markdown-content', '# Created on Mobile');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-markdown');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-markdown-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForContextFile(page, fileName);
|
|
||||||
|
|
||||||
// Verify file appears in list
|
|
||||||
const fileButton = page.locator(`[data-testid="context-file-${fileName}"]`);
|
|
||||||
await expect(fileButton).toBeVisible();
|
|
||||||
|
|
||||||
// Verify file exists on disk
|
|
||||||
expect(contextFileExistsOnDisk(fileName)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should delete a file via dropdown menu on mobile', async ({ page }) => {
|
|
||||||
const fileName = 'delete-via-menu-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// Create a test file
|
|
||||||
await clickElement(page, 'header-actions-panel-trigger');
|
|
||||||
await clickElement(page, 'create-markdown-button-mobile');
|
|
||||||
await page.waitForSelector('[data-testid="create-markdown-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-markdown-name', fileName);
|
|
||||||
await fillInput(page, 'new-markdown-content', '# File to Delete');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-markdown');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-markdown-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForContextFile(page, fileName);
|
|
||||||
|
|
||||||
// Verify file exists
|
|
||||||
expect(contextFileExistsOnDisk(fileName)).toBe(true);
|
|
||||||
|
|
||||||
// Close actions panel if still open
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
// Click on the file menu dropdown - hover first to make it visible
|
|
||||||
const fileRow = page.locator(`[data-testid="context-file-${fileName}"]`);
|
|
||||||
await fileRow.hover();
|
|
||||||
|
|
||||||
const fileMenuButton = page.locator(`[data-testid="context-file-menu-${fileName}"]`);
|
|
||||||
await fileMenuButton.click({ force: true });
|
|
||||||
|
|
||||||
// Wait for dropdown
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
// Click delete in dropdown
|
|
||||||
const deleteMenuItem = page.locator(`[data-testid="delete-context-file-${fileName}"]`);
|
|
||||||
await deleteMenuItem.click();
|
|
||||||
|
|
||||||
// Wait for file to be removed from list
|
|
||||||
await waitForElementHidden(page, `context-file-${fileName}`, { timeout: 5000 });
|
|
||||||
|
|
||||||
// Verify file no longer exists on disk
|
|
||||||
expect(contextFileExistsOnDisk(fileName)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should import file button be available in actions panel', async ({ page }) => {
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// Open actions panel
|
|
||||||
await clickElement(page, 'header-actions-panel-trigger');
|
|
||||||
|
|
||||||
// Verify import button is visible in actions panel
|
|
||||||
const importButton = page.locator('[data-testid="import-file-button-mobile"]');
|
|
||||||
await expect(importButton).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mobile Context View E2E Tests
|
|
||||||
*
|
|
||||||
* Tests for mobile-friendly behavior in the context view:
|
|
||||||
* - File list hides when file is selected on mobile
|
|
||||||
* - Back button appears on mobile to return to file list
|
|
||||||
* - Toolbar buttons are icon-only on mobile
|
|
||||||
* - Delete button is hidden on mobile (use dropdown menu instead)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect, devices } from '@playwright/test';
|
|
||||||
import {
|
|
||||||
resetContextDirectory,
|
|
||||||
setupProjectWithFixture,
|
|
||||||
getFixturePath,
|
|
||||||
navigateToContext,
|
|
||||||
waitForContextFile,
|
|
||||||
selectContextFile,
|
|
||||||
waitForFileContentToLoad,
|
|
||||||
clickElement,
|
|
||||||
fillInput,
|
|
||||||
waitForNetworkIdle,
|
|
||||||
authenticateForTests,
|
|
||||||
waitForElementHidden,
|
|
||||||
} from '../utils';
|
|
||||||
|
|
||||||
// Use mobile viewport for mobile tests in Chromium CI
|
|
||||||
test.use({ ...devices['Pixel 5'] });
|
|
||||||
|
|
||||||
test.describe('Mobile Context View', () => {
|
|
||||||
test.beforeEach(async () => {
|
|
||||||
resetContextDirectory();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterEach(async () => {
|
|
||||||
resetContextDirectory();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should hide file list when a file is selected on mobile', async ({ page }) => {
|
|
||||||
const fileName = 'mobile-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// Create a test file - on mobile, open the actions panel first
|
|
||||||
await clickElement(page, 'header-actions-panel-trigger');
|
|
||||||
await clickElement(page, 'create-markdown-button-mobile');
|
|
||||||
await page.waitForSelector('[data-testid="create-markdown-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-markdown-name', fileName);
|
|
||||||
await fillInput(
|
|
||||||
page,
|
|
||||||
'new-markdown-content',
|
|
||||||
'# Mobile Test\n\nThis tests mobile view behavior'
|
|
||||||
);
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-markdown');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-markdown-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForContextFile(page, fileName);
|
|
||||||
|
|
||||||
// File list should be visible before selection
|
|
||||||
const fileListBefore = page.locator('[data-testid="context-file-list"]');
|
|
||||||
await expect(fileListBefore).toBeVisible();
|
|
||||||
|
|
||||||
// Select the file
|
|
||||||
await selectContextFile(page, fileName);
|
|
||||||
await waitForFileContentToLoad(page);
|
|
||||||
|
|
||||||
// On mobile, file list should be hidden after selection (full-screen editor)
|
|
||||||
const fileListAfter = page.locator('[data-testid="context-file-list"]');
|
|
||||||
await expect(fileListAfter).toBeHidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show back button in editor toolbar on mobile', async ({ page }) => {
|
|
||||||
const fileName = 'back-button-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// Create a test file - on mobile, open the actions panel first
|
|
||||||
await clickElement(page, 'header-actions-panel-trigger');
|
|
||||||
await clickElement(page, 'create-markdown-button-mobile');
|
|
||||||
await page.waitForSelector('[data-testid="create-markdown-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-markdown-name', fileName);
|
|
||||||
await fillInput(
|
|
||||||
page,
|
|
||||||
'new-markdown-content',
|
|
||||||
'# Back Button Test\n\nTesting back button on mobile'
|
|
||||||
);
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-markdown');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-markdown-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForContextFile(page, fileName);
|
|
||||||
|
|
||||||
// Select the file
|
|
||||||
await selectContextFile(page, fileName);
|
|
||||||
await waitForFileContentToLoad(page);
|
|
||||||
|
|
||||||
// Back button should be visible on mobile
|
|
||||||
const backButton = page.locator('button[aria-label="Back"]');
|
|
||||||
await expect(backButton).toBeVisible();
|
|
||||||
|
|
||||||
// Back button should have ArrowLeft icon
|
|
||||||
const arrowIcon = backButton.locator('svg.lucide-arrow-left');
|
|
||||||
await expect(arrowIcon).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return to file list when back button is clicked on mobile', async ({ page }) => {
|
|
||||||
const fileName = 'back-navigation-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// Create a test file - on mobile, open the actions panel first
|
|
||||||
await clickElement(page, 'header-actions-panel-trigger');
|
|
||||||
await clickElement(page, 'create-markdown-button-mobile');
|
|
||||||
await page.waitForSelector('[data-testid="create-markdown-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-markdown-name', fileName);
|
|
||||||
await fillInput(page, 'new-markdown-content', '# Back Navigation Test');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-markdown');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-markdown-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForContextFile(page, fileName);
|
|
||||||
|
|
||||||
// Select the file
|
|
||||||
await selectContextFile(page, fileName);
|
|
||||||
await waitForFileContentToLoad(page);
|
|
||||||
|
|
||||||
// File list should be hidden after selection
|
|
||||||
const fileListHidden = page.locator('[data-testid="context-file-list"]');
|
|
||||||
await expect(fileListHidden).toBeHidden();
|
|
||||||
|
|
||||||
// Click back button
|
|
||||||
const backButton = page.locator('button[aria-label="Back"]');
|
|
||||||
await backButton.click();
|
|
||||||
|
|
||||||
// File list should be visible again
|
|
||||||
const fileListVisible = page.locator('[data-testid="context-file-list"]');
|
|
||||||
await expect(fileListVisible).toBeVisible();
|
|
||||||
|
|
||||||
// Editor should no longer be visible
|
|
||||||
const editor = page.locator('[data-testid="context-editor"]');
|
|
||||||
await expect(editor).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show icon-only buttons in toolbar on mobile', async ({ page }) => {
|
|
||||||
const fileName = 'icon-buttons-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// Create a test file - on mobile, open the actions panel first
|
|
||||||
await clickElement(page, 'header-actions-panel-trigger');
|
|
||||||
await clickElement(page, 'create-markdown-button-mobile');
|
|
||||||
await page.waitForSelector('[data-testid="create-markdown-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-markdown-name', fileName);
|
|
||||||
await fillInput(
|
|
||||||
page,
|
|
||||||
'new-markdown-content',
|
|
||||||
'# Icon Buttons Test\n\nTesting icon-only buttons on mobile'
|
|
||||||
);
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-markdown');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-markdown-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForContextFile(page, fileName);
|
|
||||||
|
|
||||||
// Select the file
|
|
||||||
await selectContextFile(page, fileName);
|
|
||||||
await waitForFileContentToLoad(page);
|
|
||||||
|
|
||||||
// Get the toggle preview mode button
|
|
||||||
const toggleButton = page.locator('[data-testid="toggle-preview-mode"]');
|
|
||||||
await expect(toggleButton).toBeVisible();
|
|
||||||
|
|
||||||
// Button should have icon (Eye or Pencil)
|
|
||||||
const eyeIcon = toggleButton.locator('svg.lucide-eye');
|
|
||||||
const pencilIcon = toggleButton.locator('svg.lucide-pencil');
|
|
||||||
|
|
||||||
// One of the icons should be present
|
|
||||||
const hasIcon = await (async () => {
|
|
||||||
const eyeVisible = await eyeIcon.isVisible().catch(() => false);
|
|
||||||
const pencilVisible = await pencilIcon.isVisible().catch(() => false);
|
|
||||||
return eyeVisible || pencilVisible;
|
|
||||||
})();
|
|
||||||
|
|
||||||
expect(hasIcon).toBe(true);
|
|
||||||
|
|
||||||
// Text label should not be present (or minimal space on mobile)
|
|
||||||
const buttonText = await toggleButton.textContent();
|
|
||||||
// On mobile, button should have icon only (no "Edit" or "Preview" text visible)
|
|
||||||
// The text is wrapped in {!isMobile && <span>}, so it shouldn't render
|
|
||||||
expect(buttonText?.trim()).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should hide delete button in toolbar on mobile', async ({ page }) => {
|
|
||||||
const fileName = 'delete-button-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// Create a test file - on mobile, open the actions panel first
|
|
||||||
await clickElement(page, 'header-actions-panel-trigger');
|
|
||||||
await clickElement(page, 'create-markdown-button-mobile');
|
|
||||||
await page.waitForSelector('[data-testid="create-markdown-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-markdown-name', fileName);
|
|
||||||
await fillInput(page, 'new-markdown-content', '# Delete Button Test');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-markdown');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-markdown-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForContextFile(page, fileName);
|
|
||||||
|
|
||||||
// Select the file
|
|
||||||
await selectContextFile(page, fileName);
|
|
||||||
await waitForFileContentToLoad(page);
|
|
||||||
|
|
||||||
// Delete button in toolbar should be hidden on mobile
|
|
||||||
const deleteButton = page.locator('[data-testid="delete-context-file"]');
|
|
||||||
await expect(deleteButton).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show file list at full width on mobile when no file is selected', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToContext(page);
|
|
||||||
|
|
||||||
// File list should be visible
|
|
||||||
const fileList = page.locator('[data-testid="context-file-list"]');
|
|
||||||
await expect(fileList).toBeVisible();
|
|
||||||
|
|
||||||
// On mobile with no file selected, the file list should take full width
|
|
||||||
// Check that the file list container has the w-full class (mobile behavior)
|
|
||||||
const fileListBox = await fileList.boundingBox();
|
|
||||||
expect(fileListBox).not.toBeNull();
|
|
||||||
|
|
||||||
if (fileListBox) {
|
|
||||||
// On mobile (Pixel 5 has width 393), the file list should take most of the width
|
|
||||||
// We check that it's significantly wider than the desktop w-64 (256px)
|
|
||||||
expect(fileListBox.width).toBeGreaterThan(300);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Editor panel should be hidden on mobile when no file is selected
|
|
||||||
const editor = page.locator('[data-testid="context-editor"]');
|
|
||||||
await expect(editor).not.toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
/**
|
|
||||||
* Desktop Memory View E2E Tests
|
|
||||||
*
|
|
||||||
* Tests for desktop behavior in the memory view:
|
|
||||||
* - File list and editor visible side-by-side
|
|
||||||
* - Back button is NOT visible on desktop
|
|
||||||
* - Toolbar buttons show both icon and text
|
|
||||||
* - Delete button is visible in toolbar (not hidden like on mobile)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import {
|
|
||||||
resetMemoryDirectory,
|
|
||||||
setupProjectWithFixture,
|
|
||||||
getFixturePath,
|
|
||||||
navigateToMemory,
|
|
||||||
waitForMemoryFile,
|
|
||||||
selectMemoryFile,
|
|
||||||
waitForMemoryContentToLoad,
|
|
||||||
clickElement,
|
|
||||||
fillInput,
|
|
||||||
waitForNetworkIdle,
|
|
||||||
authenticateForTests,
|
|
||||||
waitForElementHidden,
|
|
||||||
} from '../utils';
|
|
||||||
|
|
||||||
// Use desktop viewport for desktop tests
|
|
||||||
test.use({ viewport: { width: 1280, height: 720 } });
|
|
||||||
|
|
||||||
test.describe('Desktop Memory View', () => {
|
|
||||||
test.beforeEach(async () => {
|
|
||||||
resetMemoryDirectory();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterEach(async () => {
|
|
||||||
resetMemoryDirectory();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show file list and editor side-by-side on desktop', async ({ page }) => {
|
|
||||||
const fileName = 'desktop-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// Create a test file
|
|
||||||
await clickElement(page, 'create-memory-button');
|
|
||||||
await page.waitForSelector('[data-testid="create-memory-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-memory-name', fileName);
|
|
||||||
await fillInput(
|
|
||||||
page,
|
|
||||||
'new-memory-content',
|
|
||||||
'# Desktop Test\n\nThis tests desktop view behavior'
|
|
||||||
);
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-memory');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-memory-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForMemoryFile(page, fileName);
|
|
||||||
|
|
||||||
// Select the file
|
|
||||||
await selectMemoryFile(page, fileName);
|
|
||||||
await waitForMemoryContentToLoad(page);
|
|
||||||
|
|
||||||
// On desktop, file list should be visible after selection
|
|
||||||
const fileList = page.locator('[data-testid="memory-file-list"]');
|
|
||||||
await expect(fileList).toBeVisible();
|
|
||||||
|
|
||||||
// Editor panel should also be visible (either editor or preview)
|
|
||||||
const editor = page.locator('[data-testid="memory-editor"], [data-testid="markdown-preview"]');
|
|
||||||
await expect(editor).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should NOT show back button in editor toolbar on desktop', async ({ page }) => {
|
|
||||||
const fileName = 'no-back-button-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// Create a test file
|
|
||||||
await clickElement(page, 'create-memory-button');
|
|
||||||
await page.waitForSelector('[data-testid="create-memory-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-memory-name', fileName);
|
|
||||||
await fillInput(page, 'new-memory-content', '# No Back Button Test');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-memory');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-memory-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForMemoryFile(page, fileName);
|
|
||||||
|
|
||||||
// Select the file
|
|
||||||
await selectMemoryFile(page, fileName);
|
|
||||||
await waitForMemoryContentToLoad(page);
|
|
||||||
|
|
||||||
// Back button should NOT be visible on desktop
|
|
||||||
const backButton = page.locator('button[aria-label="Back"]');
|
|
||||||
await expect(backButton).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show buttons with text labels on desktop', async ({ page }) => {
|
|
||||||
const fileName = 'text-labels-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// Create a test file
|
|
||||||
await clickElement(page, 'create-memory-button');
|
|
||||||
await page.waitForSelector('[data-testid="create-memory-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-memory-name', fileName);
|
|
||||||
await fillInput(
|
|
||||||
page,
|
|
||||||
'new-memory-content',
|
|
||||||
'# Text Labels Test\n\nTesting button text labels on desktop'
|
|
||||||
);
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-memory');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-memory-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForMemoryFile(page, fileName);
|
|
||||||
|
|
||||||
// Select the file
|
|
||||||
await selectMemoryFile(page, fileName);
|
|
||||||
await waitForMemoryContentToLoad(page);
|
|
||||||
|
|
||||||
// Get the toggle preview mode button
|
|
||||||
const toggleButton = page.locator('[data-testid="toggle-preview-mode"]');
|
|
||||||
await expect(toggleButton).toBeVisible();
|
|
||||||
|
|
||||||
// Button should have text label on desktop
|
|
||||||
const buttonText = await toggleButton.textContent();
|
|
||||||
// On desktop, button should have visible text (Edit or Preview)
|
|
||||||
expect(buttonText?.trim()).not.toBe('');
|
|
||||||
expect(buttonText?.toLowerCase()).toMatch(/(edit|preview)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show delete button in toolbar on desktop', async ({ page }) => {
|
|
||||||
const fileName = 'delete-button-desktop-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// Create a test file
|
|
||||||
await clickElement(page, 'create-memory-button');
|
|
||||||
await page.waitForSelector('[data-testid="create-memory-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-memory-name', fileName);
|
|
||||||
await fillInput(page, 'new-memory-content', '# Delete Button Desktop Test');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-memory');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-memory-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForMemoryFile(page, fileName);
|
|
||||||
|
|
||||||
// Select the file
|
|
||||||
await selectMemoryFile(page, fileName);
|
|
||||||
await waitForMemoryContentToLoad(page);
|
|
||||||
|
|
||||||
// Delete button in toolbar should be visible on desktop
|
|
||||||
const deleteButton = page.locator('[data-testid="delete-memory-file"]');
|
|
||||||
await expect(deleteButton).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show file list at fixed width on desktop when file is selected', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
const fileName = 'fixed-width-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// Create a test file
|
|
||||||
await clickElement(page, 'create-memory-button');
|
|
||||||
await page.waitForSelector('[data-testid="create-memory-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-memory-name', fileName);
|
|
||||||
await fillInput(page, 'new-memory-content', '# Fixed Width Test');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-memory');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-memory-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForMemoryFile(page, fileName);
|
|
||||||
|
|
||||||
// Select the file
|
|
||||||
await selectMemoryFile(page, fileName);
|
|
||||||
await waitForMemoryContentToLoad(page);
|
|
||||||
|
|
||||||
// File list should be visible
|
|
||||||
const fileList = page.locator('[data-testid="memory-file-list"]');
|
|
||||||
await expect(fileList).toBeVisible();
|
|
||||||
|
|
||||||
// On desktop with file selected, the file list should be at fixed width (w-64 = 256px)
|
|
||||||
const fileListBox = await fileList.boundingBox();
|
|
||||||
expect(fileListBox).not.toBeNull();
|
|
||||||
|
|
||||||
if (fileListBox) {
|
|
||||||
// Desktop file list is w-64 = 256px, allow some tolerance for borders
|
|
||||||
expect(fileListBox.width).toBeLessThanOrEqual(300);
|
|
||||||
expect(fileListBox.width).toBeGreaterThanOrEqual(200);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show action buttons inline in header on desktop', async ({ page }) => {
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// On desktop, inline buttons should be visible
|
|
||||||
const createButton = page.locator('[data-testid="create-memory-button"]');
|
|
||||||
await expect(createButton).toBeVisible();
|
|
||||||
|
|
||||||
const refreshButton = page.locator('[data-testid="refresh-memory-button"]');
|
|
||||||
await expect(refreshButton).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
/**
|
|
||||||
* Memory View File Extension Edge Cases E2E Tests
|
|
||||||
*
|
|
||||||
* Tests for file extension handling in the memory view:
|
|
||||||
* - Files with valid markdown extensions (.md, .markdown)
|
|
||||||
* - Files without extensions (edge case for isMarkdownFile)
|
|
||||||
* - Files with multiple dots in name
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import {
|
|
||||||
resetMemoryDirectory,
|
|
||||||
setupProjectWithFixture,
|
|
||||||
getFixturePath,
|
|
||||||
navigateToMemory,
|
|
||||||
waitForMemoryFile,
|
|
||||||
selectMemoryFile,
|
|
||||||
waitForMemoryContentToLoad,
|
|
||||||
clickElement,
|
|
||||||
fillInput,
|
|
||||||
waitForNetworkIdle,
|
|
||||||
authenticateForTests,
|
|
||||||
waitForElementHidden,
|
|
||||||
createMemoryFileOnDisk,
|
|
||||||
} from '../utils';
|
|
||||||
|
|
||||||
// Use desktop viewport for these tests
|
|
||||||
test.use({ viewport: { width: 1280, height: 720 } });
|
|
||||||
|
|
||||||
test.describe('Memory View File Extension Edge Cases', () => {
|
|
||||||
test.beforeEach(async () => {
|
|
||||||
resetMemoryDirectory();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterEach(async () => {
|
|
||||||
resetMemoryDirectory();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle file with .md extension', async ({ page }) => {
|
|
||||||
const fileName = 'standard-file.md';
|
|
||||||
const content = '# Standard Markdown';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// Create file via API
|
|
||||||
createMemoryFileOnDisk(fileName, content);
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
|
|
||||||
// Refresh to load the file
|
|
||||||
await page.reload();
|
|
||||||
await waitForMemoryFile(page, fileName);
|
|
||||||
|
|
||||||
// Select and verify it opens as markdown
|
|
||||||
await selectMemoryFile(page, fileName);
|
|
||||||
await waitForMemoryContentToLoad(page);
|
|
||||||
|
|
||||||
// Should show markdown preview
|
|
||||||
const markdownPreview = page.locator('[data-testid="markdown-preview"]');
|
|
||||||
await expect(markdownPreview).toBeVisible();
|
|
||||||
|
|
||||||
// Verify content rendered
|
|
||||||
const h1 = markdownPreview.locator('h1');
|
|
||||||
await expect(h1).toHaveText('Standard Markdown');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle file with .markdown extension', async ({ page }) => {
|
|
||||||
const fileName = 'extended-extension.markdown';
|
|
||||||
const content = '# Extended Extension Test';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// Create file via API
|
|
||||||
createMemoryFileOnDisk(fileName, content);
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
|
|
||||||
// Refresh to load the file
|
|
||||||
await page.reload();
|
|
||||||
await waitForMemoryFile(page, fileName);
|
|
||||||
|
|
||||||
// Select and verify
|
|
||||||
await selectMemoryFile(page, fileName);
|
|
||||||
await waitForMemoryContentToLoad(page);
|
|
||||||
|
|
||||||
const markdownPreview = page.locator('[data-testid="markdown-preview"]');
|
|
||||||
await expect(markdownPreview).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle file with multiple dots in name', async ({ page }) => {
|
|
||||||
const fileName = 'my.detailed.notes.md';
|
|
||||||
const content = '# Multiple Dots Test';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// Create file via API
|
|
||||||
createMemoryFileOnDisk(fileName, content);
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
|
|
||||||
// Refresh to load the file
|
|
||||||
await page.reload();
|
|
||||||
await waitForMemoryFile(page, fileName);
|
|
||||||
|
|
||||||
// Select and verify - should still recognize as markdown
|
|
||||||
await selectMemoryFile(page, fileName);
|
|
||||||
await waitForMemoryContentToLoad(page);
|
|
||||||
|
|
||||||
const markdownPreview = page.locator('[data-testid="markdown-preview"]');
|
|
||||||
await expect(markdownPreview).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should NOT show file without extension in file list', async ({ page }) => {
|
|
||||||
const fileName = 'README';
|
|
||||||
const content = '# File Without Extension';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// Create file via API (without extension)
|
|
||||||
createMemoryFileOnDisk(fileName, content);
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
|
|
||||||
// Refresh to load the file
|
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Wait a moment for files to load
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
// File should NOT appear in list because isMarkdownFile returns false for no extension
|
|
||||||
const fileButton = page.locator(`[data-testid="memory-file-${fileName}"]`);
|
|
||||||
await expect(fileButton).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should NOT create file without .md extension via UI', async ({ page }) => {
|
|
||||||
const fileName = 'NOTES';
|
|
||||||
const content = '# Notes without extension';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// Create file via UI without extension
|
|
||||||
await clickElement(page, 'create-memory-button');
|
|
||||||
await page.waitForSelector('[data-testid="create-memory-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-memory-name', fileName);
|
|
||||||
await fillInput(page, 'new-memory-content', content);
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-memory');
|
|
||||||
await waitForElementHidden(page, 'create-memory-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
|
|
||||||
// File should NOT appear in list because UI enforces .md extension
|
|
||||||
// (The UI may add .md automatically or show validation error)
|
|
||||||
const fileButton = page.locator(`[data-testid="memory-file-${fileName}"]`);
|
|
||||||
await expect(fileButton)
|
|
||||||
.not.toBeVisible({ timeout: 3000 })
|
|
||||||
.catch(() => {
|
|
||||||
// It's OK if it doesn't appear - that's expected behavior
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle uppercase extensions', async ({ page }) => {
|
|
||||||
const fileName = 'uppercase.MD';
|
|
||||||
const content = '# Uppercase Extension';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// Create file via API with uppercase extension
|
|
||||||
createMemoryFileOnDisk(fileName, content);
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
|
|
||||||
// Refresh to load the file
|
|
||||||
await page.reload();
|
|
||||||
await waitForMemoryFile(page, fileName);
|
|
||||||
|
|
||||||
// Select and verify - should recognize .MD as markdown (case-insensitive)
|
|
||||||
await selectMemoryFile(page, fileName);
|
|
||||||
await waitForMemoryContentToLoad(page);
|
|
||||||
|
|
||||||
const markdownPreview = page.locator('[data-testid="markdown-preview"]');
|
|
||||||
await expect(markdownPreview).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mobile Memory View Operations E2E Tests
|
|
||||||
*
|
|
||||||
* Tests for file operations on mobile in the memory view:
|
|
||||||
* - Deleting files via dropdown menu on mobile
|
|
||||||
* - Creating files via mobile actions panel
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect, devices } from '@playwright/test';
|
|
||||||
import {
|
|
||||||
resetMemoryDirectory,
|
|
||||||
setupProjectWithFixture,
|
|
||||||
getFixturePath,
|
|
||||||
navigateToMemory,
|
|
||||||
waitForMemoryFile,
|
|
||||||
clickElement,
|
|
||||||
fillInput,
|
|
||||||
waitForNetworkIdle,
|
|
||||||
authenticateForTests,
|
|
||||||
memoryFileExistsOnDisk,
|
|
||||||
waitForElementHidden,
|
|
||||||
} from '../utils';
|
|
||||||
|
|
||||||
// Use mobile viewport for mobile tests in Chromium CI
|
|
||||||
test.use({ ...devices['Pixel 5'] });
|
|
||||||
|
|
||||||
test.describe('Mobile Memory View Operations', () => {
|
|
||||||
test.beforeEach(async () => {
|
|
||||||
resetMemoryDirectory();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterEach(async () => {
|
|
||||||
resetMemoryDirectory();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should create a file via mobile actions panel', async ({ page }) => {
|
|
||||||
const fileName = 'mobile-created.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// Create a test file via mobile actions panel
|
|
||||||
await clickElement(page, 'header-actions-panel-trigger');
|
|
||||||
await clickElement(page, 'create-memory-button-mobile');
|
|
||||||
await page.waitForSelector('[data-testid="create-memory-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-memory-name', fileName);
|
|
||||||
await fillInput(page, 'new-memory-content', '# Created on Mobile');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-memory');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-memory-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForMemoryFile(page, fileName);
|
|
||||||
|
|
||||||
// Verify file appears in list
|
|
||||||
const fileButton = page.locator(`[data-testid="memory-file-${fileName}"]`);
|
|
||||||
await expect(fileButton).toBeVisible();
|
|
||||||
|
|
||||||
// Verify file exists on disk
|
|
||||||
expect(memoryFileExistsOnDisk(fileName)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should delete a file via dropdown menu on mobile', async ({ page }) => {
|
|
||||||
const fileName = 'delete-via-menu-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// Create a test file
|
|
||||||
await clickElement(page, 'header-actions-panel-trigger');
|
|
||||||
await clickElement(page, 'create-memory-button-mobile');
|
|
||||||
await page.waitForSelector('[data-testid="create-memory-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-memory-name', fileName);
|
|
||||||
await fillInput(page, 'new-memory-content', '# File to Delete');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-memory');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-memory-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForMemoryFile(page, fileName);
|
|
||||||
|
|
||||||
// Verify file exists
|
|
||||||
expect(memoryFileExistsOnDisk(fileName)).toBe(true);
|
|
||||||
|
|
||||||
// Close actions panel if still open
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
// Click on the file menu dropdown - hover first to make it visible
|
|
||||||
const fileRow = page.locator(`[data-testid="memory-file-${fileName}"]`);
|
|
||||||
await fileRow.hover();
|
|
||||||
|
|
||||||
const fileMenuButton = page.locator(`[data-testid="memory-file-menu-${fileName}"]`);
|
|
||||||
await fileMenuButton.click({ force: true });
|
|
||||||
|
|
||||||
// Wait for dropdown
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
// Click delete in dropdown
|
|
||||||
const deleteMenuItem = page.locator(`[data-testid="delete-memory-file-${fileName}"]`);
|
|
||||||
await deleteMenuItem.click();
|
|
||||||
|
|
||||||
// Wait for file to be removed from list
|
|
||||||
await waitForElementHidden(page, `memory-file-${fileName}`, { timeout: 5000 });
|
|
||||||
|
|
||||||
// Verify file no longer exists on disk
|
|
||||||
expect(memoryFileExistsOnDisk(fileName)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should refresh button be available in actions panel', async ({ page }) => {
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// Open actions panel
|
|
||||||
await clickElement(page, 'header-actions-panel-trigger');
|
|
||||||
|
|
||||||
// Verify refresh button is visible in actions panel
|
|
||||||
const refreshButton = page.locator('[data-testid="refresh-memory-button-mobile"]');
|
|
||||||
await expect(refreshButton).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should preview markdown content on mobile', async ({ page }) => {
|
|
||||||
const fileName = 'preview-test.md';
|
|
||||||
const markdownContent =
|
|
||||||
'# Preview Test\n\n**Bold text** and *italic text*\n\n- List item 1\n- List item 2';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// Create a test file
|
|
||||||
await clickElement(page, 'header-actions-panel-trigger');
|
|
||||||
await clickElement(page, 'create-memory-button-mobile');
|
|
||||||
await page.waitForSelector('[data-testid="create-memory-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-memory-name', fileName);
|
|
||||||
await fillInput(page, 'new-memory-content', markdownContent);
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-memory');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-memory-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForMemoryFile(page, fileName);
|
|
||||||
|
|
||||||
// Select the file by clicking on it
|
|
||||||
const fileButton = page.locator(`[data-testid="memory-file-${fileName}"]`);
|
|
||||||
await fileButton.click();
|
|
||||||
|
|
||||||
// Wait for content to load (preview or editor)
|
|
||||||
await page.waitForSelector('[data-testid="markdown-preview"], [data-testid="memory-editor"]', {
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Memory files open in preview mode by default
|
|
||||||
const markdownPreview = page.locator('[data-testid="markdown-preview"]');
|
|
||||||
await expect(markdownPreview).toBeVisible();
|
|
||||||
|
|
||||||
// Verify the preview rendered the markdown (check for h1)
|
|
||||||
const h1 = markdownPreview.locator('h1');
|
|
||||||
await expect(h1).toHaveText('Preview Test');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
/**
|
|
||||||
* Mobile Memory View E2E Tests
|
|
||||||
*
|
|
||||||
* Tests for mobile-friendly behavior in the memory view:
|
|
||||||
* - File list hides when file is selected on mobile
|
|
||||||
* - Back button appears on mobile to return to file list
|
|
||||||
* - Toolbar buttons are icon-only on mobile
|
|
||||||
* - Delete button is hidden on mobile (use dropdown menu instead)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect, devices } from '@playwright/test';
|
|
||||||
import {
|
|
||||||
resetMemoryDirectory,
|
|
||||||
setupProjectWithFixture,
|
|
||||||
getFixturePath,
|
|
||||||
navigateToMemory,
|
|
||||||
waitForMemoryFile,
|
|
||||||
selectMemoryFile,
|
|
||||||
waitForMemoryContentToLoad,
|
|
||||||
clickElement,
|
|
||||||
fillInput,
|
|
||||||
waitForNetworkIdle,
|
|
||||||
authenticateForTests,
|
|
||||||
waitForElementHidden,
|
|
||||||
} from '../utils';
|
|
||||||
|
|
||||||
// Use mobile viewport for mobile tests in Chromium CI
|
|
||||||
test.use({ ...devices['Pixel 5'] });
|
|
||||||
|
|
||||||
test.describe('Mobile Memory View', () => {
|
|
||||||
test.beforeEach(async () => {
|
|
||||||
resetMemoryDirectory();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterEach(async () => {
|
|
||||||
resetMemoryDirectory();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should hide file list when a file is selected on mobile', async ({ page }) => {
|
|
||||||
const fileName = 'mobile-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// Create a test file - on mobile, open the actions panel first
|
|
||||||
await clickElement(page, 'header-actions-panel-trigger');
|
|
||||||
await clickElement(page, 'create-memory-button-mobile');
|
|
||||||
await page.waitForSelector('[data-testid="create-memory-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-memory-name', fileName);
|
|
||||||
await fillInput(page, 'new-memory-content', '# Mobile Test\n\nThis tests mobile view behavior');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-memory');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-memory-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForMemoryFile(page, fileName);
|
|
||||||
|
|
||||||
// File list should be visible before selection
|
|
||||||
const fileListBefore = page.locator('[data-testid="memory-file-list"]');
|
|
||||||
await expect(fileListBefore).toBeVisible();
|
|
||||||
|
|
||||||
// Select the file
|
|
||||||
await selectMemoryFile(page, fileName);
|
|
||||||
await waitForMemoryContentToLoad(page);
|
|
||||||
|
|
||||||
// On mobile, file list should be hidden after selection (full-screen editor)
|
|
||||||
const fileListAfter = page.locator('[data-testid="memory-file-list"]');
|
|
||||||
await expect(fileListAfter).toBeHidden();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show back button in editor toolbar on mobile', async ({ page }) => {
|
|
||||||
const fileName = 'back-button-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// Create a test file - on mobile, open the actions panel first
|
|
||||||
await clickElement(page, 'header-actions-panel-trigger');
|
|
||||||
await clickElement(page, 'create-memory-button-mobile');
|
|
||||||
await page.waitForSelector('[data-testid="create-memory-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-memory-name', fileName);
|
|
||||||
await fillInput(
|
|
||||||
page,
|
|
||||||
'new-memory-content',
|
|
||||||
'# Back Button Test\n\nTesting back button on mobile'
|
|
||||||
);
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-memory');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-memory-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForMemoryFile(page, fileName);
|
|
||||||
|
|
||||||
// Select the file
|
|
||||||
await selectMemoryFile(page, fileName);
|
|
||||||
await waitForMemoryContentToLoad(page);
|
|
||||||
|
|
||||||
// Back button should be visible on mobile
|
|
||||||
const backButton = page.locator('button[aria-label="Back"]');
|
|
||||||
await expect(backButton).toBeVisible();
|
|
||||||
|
|
||||||
// Back button should have ArrowLeft icon
|
|
||||||
const arrowIcon = backButton.locator('svg.lucide-arrow-left');
|
|
||||||
await expect(arrowIcon).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return to file list when back button is clicked on mobile', async ({ page }) => {
|
|
||||||
const fileName = 'back-navigation-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// Create a test file - on mobile, open the actions panel first
|
|
||||||
await clickElement(page, 'header-actions-panel-trigger');
|
|
||||||
await clickElement(page, 'create-memory-button-mobile');
|
|
||||||
await page.waitForSelector('[data-testid="create-memory-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-memory-name', fileName);
|
|
||||||
await fillInput(page, 'new-memory-content', '# Back Navigation Test');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-memory');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-memory-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForMemoryFile(page, fileName);
|
|
||||||
|
|
||||||
// Select the file
|
|
||||||
await selectMemoryFile(page, fileName);
|
|
||||||
await waitForMemoryContentToLoad(page);
|
|
||||||
|
|
||||||
// File list should be hidden after selection
|
|
||||||
const fileListHidden = page.locator('[data-testid="memory-file-list"]');
|
|
||||||
await expect(fileListHidden).toBeHidden();
|
|
||||||
|
|
||||||
// Click back button
|
|
||||||
const backButton = page.locator('button[aria-label="Back"]');
|
|
||||||
await backButton.click();
|
|
||||||
|
|
||||||
// File list should be visible again
|
|
||||||
const fileListVisible = page.locator('[data-testid="memory-file-list"]');
|
|
||||||
await expect(fileListVisible).toBeVisible();
|
|
||||||
|
|
||||||
// Editor should no longer be visible
|
|
||||||
const editor = page.locator('[data-testid="memory-editor"]');
|
|
||||||
await expect(editor).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show icon-only buttons in toolbar on mobile', async ({ page }) => {
|
|
||||||
const fileName = 'icon-buttons-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// Create a test file - on mobile, open the actions panel first
|
|
||||||
await clickElement(page, 'header-actions-panel-trigger');
|
|
||||||
await clickElement(page, 'create-memory-button-mobile');
|
|
||||||
await page.waitForSelector('[data-testid="create-memory-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-memory-name', fileName);
|
|
||||||
await fillInput(
|
|
||||||
page,
|
|
||||||
'new-memory-content',
|
|
||||||
'# Icon Buttons Test\n\nTesting icon-only buttons on mobile'
|
|
||||||
);
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-memory');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-memory-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForMemoryFile(page, fileName);
|
|
||||||
|
|
||||||
// Select the file
|
|
||||||
await selectMemoryFile(page, fileName);
|
|
||||||
await waitForMemoryContentToLoad(page);
|
|
||||||
|
|
||||||
// Get the toggle preview mode button
|
|
||||||
const toggleButton = page.locator('[data-testid="toggle-preview-mode"]');
|
|
||||||
await expect(toggleButton).toBeVisible();
|
|
||||||
|
|
||||||
// Button should have icon (Eye or Pencil)
|
|
||||||
const eyeIcon = toggleButton.locator('svg.lucide-eye');
|
|
||||||
const pencilIcon = toggleButton.locator('svg.lucide-pencil');
|
|
||||||
|
|
||||||
// One of the icons should be present
|
|
||||||
const hasIcon = await (async () => {
|
|
||||||
const eyeVisible = await eyeIcon.isVisible().catch(() => false);
|
|
||||||
const pencilVisible = await pencilIcon.isVisible().catch(() => false);
|
|
||||||
return eyeVisible || pencilVisible;
|
|
||||||
})();
|
|
||||||
|
|
||||||
expect(hasIcon).toBe(true);
|
|
||||||
|
|
||||||
// Text label should not be present (or minimal space on mobile)
|
|
||||||
const buttonText = await toggleButton.textContent();
|
|
||||||
// On mobile, button should have icon only (no "Edit" or "Preview" text visible)
|
|
||||||
// The text is wrapped in {!isMobile && <span>}, so it shouldn't render
|
|
||||||
expect(buttonText?.trim()).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should hide delete button in toolbar on mobile', async ({ page }) => {
|
|
||||||
const fileName = 'delete-button-test.md';
|
|
||||||
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// Create a test file - on mobile, open the actions panel first
|
|
||||||
await clickElement(page, 'header-actions-panel-trigger');
|
|
||||||
await clickElement(page, 'create-memory-button-mobile');
|
|
||||||
await page.waitForSelector('[data-testid="create-memory-dialog"]', { timeout: 5000 });
|
|
||||||
|
|
||||||
await fillInput(page, 'new-memory-name', fileName);
|
|
||||||
await fillInput(page, 'new-memory-content', '# Delete Button Test');
|
|
||||||
|
|
||||||
await clickElement(page, 'confirm-create-memory');
|
|
||||||
|
|
||||||
await waitForElementHidden(page, 'create-memory-dialog', { timeout: 5000 });
|
|
||||||
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await waitForMemoryFile(page, fileName);
|
|
||||||
|
|
||||||
// Select the file
|
|
||||||
await selectMemoryFile(page, fileName);
|
|
||||||
await waitForMemoryContentToLoad(page);
|
|
||||||
|
|
||||||
// Delete button in toolbar should be hidden on mobile
|
|
||||||
const deleteButton = page.locator('[data-testid="delete-memory-file"]');
|
|
||||||
await expect(deleteButton).not.toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show file list at full width on mobile when no file is selected', async ({
|
|
||||||
page,
|
|
||||||
}) => {
|
|
||||||
await setupProjectWithFixture(page, getFixturePath());
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
await navigateToMemory(page);
|
|
||||||
|
|
||||||
// File list should be visible
|
|
||||||
const fileList = page.locator('[data-testid="memory-file-list"]');
|
|
||||||
await expect(fileList).toBeVisible();
|
|
||||||
|
|
||||||
// On mobile with no file selected, the file list should take full width
|
|
||||||
// Check that the file list container has the w-full class (mobile behavior)
|
|
||||||
const fileListBox = await fileList.boundingBox();
|
|
||||||
expect(fileListBox).not.toBeNull();
|
|
||||||
|
|
||||||
if (fileListBox) {
|
|
||||||
// On mobile (Pixel 5 has width 393), the file list should take most of the width
|
|
||||||
// We check that it's significantly wider than the desktop w-64 (256px)
|
|
||||||
expect(fileListBox.width).toBeGreaterThan(300);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Editor panel should be hidden on mobile when no file is selected
|
|
||||||
const editor = page.locator('[data-testid="memory-editor"]');
|
|
||||||
await expect(editor).not.toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
/**
|
|
||||||
* AI Profiles E2E Test
|
|
||||||
*
|
|
||||||
* Happy path: Create a new profile
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import {
|
|
||||||
setupMockProjectWithProfiles,
|
|
||||||
waitForNetworkIdle,
|
|
||||||
navigateToProfiles,
|
|
||||||
clickNewProfileButton,
|
|
||||||
fillProfileForm,
|
|
||||||
saveProfile,
|
|
||||||
waitForSuccessToast,
|
|
||||||
countCustomProfiles,
|
|
||||||
authenticateForTests,
|
|
||||||
handleLoginScreenIfPresent,
|
|
||||||
} from '../utils';
|
|
||||||
|
|
||||||
test.describe('AI Profiles', () => {
|
|
||||||
// Skip: The profiles UI (standalone nav item, profile cards, add/edit dialogs)
|
|
||||||
// has not been implemented yet. The test references data-testid values that
|
|
||||||
// do not exist in the current codebase.
|
|
||||||
test.skip('should create a new profile', async ({ page }) => {
|
|
||||||
await setupMockProjectWithProfiles(page, { customProfilesCount: 0 });
|
|
||||||
await authenticateForTests(page);
|
|
||||||
await page.goto('/');
|
|
||||||
await page.waitForLoadState('load');
|
|
||||||
await handleLoginScreenIfPresent(page);
|
|
||||||
await waitForNetworkIdle(page);
|
|
||||||
await navigateToProfiles(page);
|
|
||||||
|
|
||||||
// Get initial custom profile count (may be 0 or more due to server settings hydration)
|
|
||||||
const initialCount = await countCustomProfiles(page);
|
|
||||||
|
|
||||||
await clickNewProfileButton(page);
|
|
||||||
|
|
||||||
await fillProfileForm(page, {
|
|
||||||
name: 'Test Profile',
|
|
||||||
description: 'A test profile',
|
|
||||||
icon: 'Brain',
|
|
||||||
model: 'sonnet',
|
|
||||||
thinkingLevel: 'medium',
|
|
||||||
});
|
|
||||||
|
|
||||||
await saveProfile(page);
|
|
||||||
|
|
||||||
await waitForSuccessToast(page, 'Profile created');
|
|
||||||
|
|
||||||
// Wait for the new profile to appear in the list (replaces arbitrary timeout)
|
|
||||||
// The count should increase by 1 from the initial count
|
|
||||||
await expect(async () => {
|
|
||||||
const customCount = await countCustomProfiles(page);
|
|
||||||
expect(customCount).toBe(initialCount + 1);
|
|
||||||
}).toPass({ timeout: 5000 });
|
|
||||||
|
|
||||||
// Verify the count is correct (final assertion)
|
|
||||||
const finalCount = await countCustomProfiles(page);
|
|
||||||
expect(finalCount).toBe(initialCount + 1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,491 +0,0 @@
|
|||||||
/**
|
|
||||||
* Board Background Persistence End-to-End Test
|
|
||||||
*
|
|
||||||
* Tests that board background settings are properly saved and loaded when switching projects.
|
|
||||||
* This verifies that:
|
|
||||||
* 1. Background settings are saved to .automaker-local/settings.json
|
|
||||||
* 2. Settings are loaded when switching back to a project
|
|
||||||
* 3. Background image, opacity, and other settings are correctly restored
|
|
||||||
* 4. Settings persist across app restarts (new page loads)
|
|
||||||
*
|
|
||||||
* This test prevents regression of the board background loading bug where
|
|
||||||
* settings were saved but never loaded when switching projects.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import {
|
|
||||||
createTempDirPath,
|
|
||||||
cleanupTempDir,
|
|
||||||
authenticateForTests,
|
|
||||||
handleLoginScreenIfPresent,
|
|
||||||
} from '../utils';
|
|
||||||
|
|
||||||
// Create unique temp dirs for this test run
|
|
||||||
const TEST_TEMP_DIR = createTempDirPath('board-bg-test');
|
|
||||||
|
|
||||||
test.describe('Board Background Persistence', () => {
|
|
||||||
test.beforeAll(async () => {
|
|
||||||
// Create test temp directory
|
|
||||||
if (!fs.existsSync(TEST_TEMP_DIR)) {
|
|
||||||
fs.mkdirSync(TEST_TEMP_DIR, { recursive: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
// Cleanup temp directory
|
|
||||||
cleanupTempDir(TEST_TEMP_DIR);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should load board background settings when switching projects', async ({ page }) => {
|
|
||||||
const projectAName = `project-a-${Date.now()}`;
|
|
||||||
const projectBName = `project-b-${Date.now()}`;
|
|
||||||
const projectAPath = path.join(TEST_TEMP_DIR, projectAName);
|
|
||||||
const projectBPath = path.join(TEST_TEMP_DIR, projectBName);
|
|
||||||
const projectAId = `project-a-${Date.now()}`;
|
|
||||||
const projectBId = `project-b-${Date.now()}`;
|
|
||||||
|
|
||||||
// Create both project directories
|
|
||||||
fs.mkdirSync(projectAPath, { recursive: true });
|
|
||||||
fs.mkdirSync(projectBPath, { recursive: true });
|
|
||||||
|
|
||||||
// Create basic files for both projects
|
|
||||||
for (const [name, projectPath] of [
|
|
||||||
[projectAName, projectAPath],
|
|
||||||
[projectBName, projectBPath],
|
|
||||||
]) {
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(projectPath, 'package.json'),
|
|
||||||
JSON.stringify({ name, version: '1.0.0' }, null, 2)
|
|
||||||
);
|
|
||||||
fs.writeFileSync(path.join(projectPath, 'README.md'), `# ${name}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create .automaker-local directory for project A with background settings
|
|
||||||
const automakerDirA = path.join(projectAPath, '.automaker-local');
|
|
||||||
fs.mkdirSync(automakerDirA, { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(automakerDirA, 'board'), { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(automakerDirA, 'features'), { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(automakerDirA, 'context'), { recursive: true });
|
|
||||||
|
|
||||||
// Copy actual background image from test fixtures
|
|
||||||
const backgroundPath = path.join(automakerDirA, 'board', 'background.jpg');
|
|
||||||
const testImagePath = path.join(__dirname, '..', 'img', 'background.jpg');
|
|
||||||
fs.copyFileSync(testImagePath, backgroundPath);
|
|
||||||
|
|
||||||
// Create settings.json with board background configuration
|
|
||||||
const settingsPath = path.join(automakerDirA, 'settings.json');
|
|
||||||
const backgroundSettings = {
|
|
||||||
version: 1,
|
|
||||||
boardBackground: {
|
|
||||||
imagePath: backgroundPath,
|
|
||||||
cardOpacity: 85,
|
|
||||||
columnOpacity: 60,
|
|
||||||
columnBorderEnabled: true,
|
|
||||||
cardGlassmorphism: true,
|
|
||||||
cardBorderEnabled: false,
|
|
||||||
cardBorderOpacity: 50,
|
|
||||||
hideScrollbar: true,
|
|
||||||
imageVersion: Date.now(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
fs.writeFileSync(settingsPath, JSON.stringify(backgroundSettings, null, 2));
|
|
||||||
|
|
||||||
// Create minimal automaker-local directory for project B (no background)
|
|
||||||
const automakerDirB = path.join(projectBPath, '.automaker-local');
|
|
||||||
fs.mkdirSync(automakerDirB, { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(automakerDirB, 'features'), { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(automakerDirB, 'context'), { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(automakerDirB, 'settings.json'),
|
|
||||||
JSON.stringify({ version: 1 }, null, 2)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set up project A as the current project directly (skip welcome view).
|
|
||||||
// The auto-open logic in __root.tsx always opens the most recent project when
|
|
||||||
// navigating to /, so we cannot reliably show the welcome view with projects.
|
|
||||||
const projectA = {
|
|
||||||
id: projectAId,
|
|
||||||
name: projectAName,
|
|
||||||
path: projectAPath,
|
|
||||||
lastOpened: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
const projectB = {
|
|
||||||
id: projectBId,
|
|
||||||
name: projectBName,
|
|
||||||
path: projectBPath,
|
|
||||||
lastOpened: new Date(Date.now() - 86400000).toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await page.addInitScript(
|
|
||||||
({
|
|
||||||
projects,
|
|
||||||
versions,
|
|
||||||
}: {
|
|
||||||
projects: Array<{ id: string; name: string; path: string; lastOpened: string }>;
|
|
||||||
versions: { APP_STORE: number; SETUP_STORE: number };
|
|
||||||
}) => {
|
|
||||||
const appState = {
|
|
||||||
state: {
|
|
||||||
projects: projects,
|
|
||||||
currentProject: projects[0],
|
|
||||||
currentView: 'board',
|
|
||||||
theme: 'dark',
|
|
||||||
sidebarOpen: true,
|
|
||||||
skipSandboxWarning: true,
|
|
||||||
apiKeys: { anthropic: '', google: '' },
|
|
||||||
chatSessions: [],
|
|
||||||
chatHistoryOpen: false,
|
|
||||||
maxConcurrency: 3,
|
|
||||||
boardBackgroundByProject: {},
|
|
||||||
},
|
|
||||||
version: versions.APP_STORE,
|
|
||||||
};
|
|
||||||
localStorage.setItem('automaker-storage', JSON.stringify(appState));
|
|
||||||
|
|
||||||
const setupState = {
|
|
||||||
state: {
|
|
||||||
isFirstRun: false,
|
|
||||||
setupComplete: true,
|
|
||||||
skipClaudeSetup: false,
|
|
||||||
},
|
|
||||||
version: versions.SETUP_STORE,
|
|
||||||
};
|
|
||||||
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
|
||||||
|
|
||||||
const settingsCache = {
|
|
||||||
setupComplete: true,
|
|
||||||
isFirstRun: false,
|
|
||||||
projects: projects.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
name: p.name,
|
|
||||||
path: p.path,
|
|
||||||
lastOpened: p.lastOpened,
|
|
||||||
})),
|
|
||||||
currentProjectId: projects[0].id,
|
|
||||||
theme: 'dark',
|
|
||||||
sidebarOpen: true,
|
|
||||||
maxConcurrency: 3,
|
|
||||||
};
|
|
||||||
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
|
||||||
|
|
||||||
localStorage.setItem('automaker-disable-splash', 'true');
|
|
||||||
},
|
|
||||||
{ projects: [projectA, projectB], versions: { APP_STORE: 2, SETUP_STORE: 1 } }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Intercept settings API BEFORE authentication to ensure our test projects
|
|
||||||
// are consistently returned by the server. Only intercept GET requests -
|
|
||||||
// let PUT requests (settings saves) pass through unmodified.
|
|
||||||
await page.route('**/api/settings/global', async (route) => {
|
|
||||||
if (route.request().method() !== 'GET') {
|
|
||||||
await route.continue();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const response = await route.fetch();
|
|
||||||
const json = await response.json();
|
|
||||||
if (json.settings) {
|
|
||||||
json.settings.currentProjectId = projectAId;
|
|
||||||
json.settings.projects = [projectA, projectB];
|
|
||||||
}
|
|
||||||
await route.fulfill({ response, json });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track API calls to /api/settings/project to verify settings are being loaded
|
|
||||||
const settingsApiCalls: Array<{ url: string; method: string; body: string }> = [];
|
|
||||||
page.on('request', (request) => {
|
|
||||||
if (request.url().includes('/api/settings/project') && request.method() === 'POST') {
|
|
||||||
settingsApiCalls.push({
|
|
||||||
url: request.url(),
|
|
||||||
method: request.method(),
|
|
||||||
body: request.postData() || '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
// Navigate to the board directly with project A
|
|
||||||
await page.goto('/board');
|
|
||||||
await page.waitForLoadState('load');
|
|
||||||
await handleLoginScreenIfPresent(page);
|
|
||||||
|
|
||||||
// Wait for board view
|
|
||||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
|
|
||||||
|
|
||||||
// CRITICAL: Wait for settings to be loaded (useProjectSettingsLoader hook)
|
|
||||||
// This ensures the background settings are fetched from the server
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Check if background settings were applied by checking the store
|
|
||||||
// We can't directly access React state, so we'll verify via DOM/CSS
|
|
||||||
const boardView = page.locator('[data-testid="board-view"]');
|
|
||||||
await expect(boardView).toBeVisible();
|
|
||||||
|
|
||||||
// Wait for initial project load to stabilize
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
// Ensure sidebar is expanded before interacting with project selector
|
|
||||||
const expandSidebarButton = page.locator('button:has-text("Expand sidebar")');
|
|
||||||
if (await expandSidebarButton.isVisible()) {
|
|
||||||
await expandSidebarButton.click();
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Switch to project B (no background)
|
|
||||||
const projectSelector = page.locator('[data-testid="project-dropdown-trigger"]');
|
|
||||||
await expect(projectSelector).toBeVisible({ timeout: 5000 });
|
|
||||||
await projectSelector.click();
|
|
||||||
|
|
||||||
// Wait for dropdown to be visible
|
|
||||||
await expect(page.locator('[data-testid="project-dropdown-content"]')).toBeVisible({
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const projectPickerB = page.locator(`[data-testid="project-item-${projectBId}"]`);
|
|
||||||
await expect(projectPickerB).toBeVisible({ timeout: 5000 });
|
|
||||||
await projectPickerB.click();
|
|
||||||
|
|
||||||
// Wait for project B to load
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="project-dropdown-trigger"]').getByText(projectBName)
|
|
||||||
).toBeVisible({ timeout: 5000 });
|
|
||||||
|
|
||||||
// Wait a bit for project B to fully load before switching
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
// Switch back to project A
|
|
||||||
await projectSelector.click();
|
|
||||||
|
|
||||||
// Wait for dropdown to be visible
|
|
||||||
await expect(page.locator('[data-testid="project-dropdown-content"]')).toBeVisible({
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const projectPickerA = page.locator(`[data-testid="project-item-${projectAId}"]`);
|
|
||||||
await expect(projectPickerA).toBeVisible({ timeout: 5000 });
|
|
||||||
await projectPickerA.click();
|
|
||||||
|
|
||||||
// Verify we're back on project A
|
|
||||||
await expect(
|
|
||||||
page.locator('[data-testid="project-dropdown-trigger"]').getByText(projectAName)
|
|
||||||
).toBeVisible({ timeout: 5000 });
|
|
||||||
|
|
||||||
// CRITICAL: Wait for settings to be loaded again
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Verify that the settings API was called for project A at least once (initial load).
|
|
||||||
// Note: When switching back, the app may use cached settings and skip re-fetching.
|
|
||||||
const projectASettingsCalls = settingsApiCalls.filter((call) =>
|
|
||||||
call.body.includes(projectAPath)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Debug: log all API calls if test fails
|
|
||||||
if (projectASettingsCalls.length < 1) {
|
|
||||||
console.log('Total settings API calls:', settingsApiCalls.length);
|
|
||||||
console.log('API calls:', JSON.stringify(settingsApiCalls, null, 2));
|
|
||||||
console.log('Looking for path:', projectAPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(projectASettingsCalls.length).toBeGreaterThanOrEqual(1);
|
|
||||||
|
|
||||||
// Verify settings file still exists with correct data
|
|
||||||
const loadedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
||||||
expect(loadedSettings.boardBackground).toBeDefined();
|
|
||||||
expect(loadedSettings.boardBackground.imagePath).toBe(backgroundPath);
|
|
||||||
expect(loadedSettings.boardBackground.cardOpacity).toBe(85);
|
|
||||||
expect(loadedSettings.boardBackground.columnOpacity).toBe(60);
|
|
||||||
expect(loadedSettings.boardBackground.hideScrollbar).toBe(true);
|
|
||||||
|
|
||||||
// Clean up route handlers to avoid "route in flight" errors during teardown
|
|
||||||
await page.unrouteAll({ behavior: 'ignoreErrors' });
|
|
||||||
|
|
||||||
// The test passing means:
|
|
||||||
// 1. The useProjectSettingsLoader hook is working
|
|
||||||
// 2. Settings are loaded when switching projects
|
|
||||||
// 3. The API call to /api/settings/project is made correctly
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should load background settings on app restart', async ({ page }) => {
|
|
||||||
const projectName = `restart-test-${Date.now()}`;
|
|
||||||
const projectPath = path.join(TEST_TEMP_DIR, projectName);
|
|
||||||
const projectId = `project-${Date.now()}`;
|
|
||||||
|
|
||||||
// Create project directory
|
|
||||||
fs.mkdirSync(projectPath, { recursive: true });
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(projectPath, 'package.json'),
|
|
||||||
JSON.stringify({ name: projectName, version: '1.0.0' }, null, 2)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create .automaker-local with background settings
|
|
||||||
const automakerDir = path.join(projectPath, '.automaker-local');
|
|
||||||
fs.mkdirSync(automakerDir, { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(automakerDir, 'board'), { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(automakerDir, 'features'), { recursive: true });
|
|
||||||
fs.mkdirSync(path.join(automakerDir, 'context'), { recursive: true });
|
|
||||||
|
|
||||||
// Copy actual background image from test fixtures
|
|
||||||
const backgroundPath = path.join(automakerDir, 'board', 'background.jpg');
|
|
||||||
const testImagePath = path.join(__dirname, '..', 'img', 'background.jpg');
|
|
||||||
fs.copyFileSync(testImagePath, backgroundPath);
|
|
||||||
|
|
||||||
const settingsPath = path.join(automakerDir, 'settings.json');
|
|
||||||
fs.writeFileSync(
|
|
||||||
settingsPath,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
version: 1,
|
|
||||||
boardBackground: {
|
|
||||||
imagePath: backgroundPath,
|
|
||||||
cardOpacity: 90,
|
|
||||||
columnOpacity: 70,
|
|
||||||
imageVersion: Date.now(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Set up with project as current using direct localStorage
|
|
||||||
await page.addInitScript(
|
|
||||||
({ project }: { project: string[] }) => {
|
|
||||||
const projectObj = {
|
|
||||||
id: project[0],
|
|
||||||
name: project[1],
|
|
||||||
path: project[2],
|
|
||||||
lastOpened: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const appState = {
|
|
||||||
state: {
|
|
||||||
projects: [projectObj],
|
|
||||||
currentProject: projectObj,
|
|
||||||
currentView: 'board',
|
|
||||||
theme: 'dark',
|
|
||||||
sidebarOpen: true,
|
|
||||||
skipSandboxWarning: true,
|
|
||||||
apiKeys: { anthropic: '', google: '' },
|
|
||||||
chatSessions: [],
|
|
||||||
chatHistoryOpen: false,
|
|
||||||
maxConcurrency: 3,
|
|
||||||
boardBackgroundByProject: {},
|
|
||||||
},
|
|
||||||
version: 2,
|
|
||||||
};
|
|
||||||
localStorage.setItem('automaker-storage', JSON.stringify(appState));
|
|
||||||
|
|
||||||
// Setup complete - use correct key name
|
|
||||||
const setupState = {
|
|
||||||
state: {
|
|
||||||
isFirstRun: false,
|
|
||||||
setupComplete: true,
|
|
||||||
skipClaudeSetup: false,
|
|
||||||
},
|
|
||||||
version: 1,
|
|
||||||
};
|
|
||||||
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
|
||||||
|
|
||||||
const settingsCache = {
|
|
||||||
setupComplete: true,
|
|
||||||
isFirstRun: false,
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
id: projectObj.id,
|
|
||||||
name: projectObj.name,
|
|
||||||
path: projectObj.path,
|
|
||||||
lastOpened: projectObj.lastOpened,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
currentProjectId: projectObj.id,
|
|
||||||
theme: 'dark',
|
|
||||||
sidebarOpen: true,
|
|
||||||
maxConcurrency: 3,
|
|
||||||
};
|
|
||||||
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
|
||||||
|
|
||||||
// Disable splash screen in tests
|
|
||||||
localStorage.setItem('automaker-disable-splash', 'true');
|
|
||||||
},
|
|
||||||
{ project: [projectId, projectName, projectPath] }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Intercept settings API to use our test project instead of the E2E fixture.
|
|
||||||
// Only intercept GET requests - let PUT requests pass through unmodified.
|
|
||||||
await page.route('**/api/settings/global', async (route) => {
|
|
||||||
if (route.request().method() !== 'GET') {
|
|
||||||
await route.continue();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const response = await route.fetch();
|
|
||||||
const json = await response.json();
|
|
||||||
// Override to use our test project
|
|
||||||
if (json.settings) {
|
|
||||||
json.settings.currentProjectId = projectId;
|
|
||||||
json.settings.projects = [
|
|
||||||
{
|
|
||||||
id: projectId,
|
|
||||||
name: projectName,
|
|
||||||
path: projectPath,
|
|
||||||
lastOpened: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
await route.fulfill({ response, json });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track API calls to /api/settings/project to verify settings are being loaded
|
|
||||||
const settingsApiCalls: Array<{ url: string; method: string; body: string }> = [];
|
|
||||||
page.on('request', (request) => {
|
|
||||||
if (request.url().includes('/api/settings/project') && request.method() === 'POST') {
|
|
||||||
settingsApiCalls.push({
|
|
||||||
url: request.url(),
|
|
||||||
method: request.method(),
|
|
||||||
body: request.postData() || '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
// Navigate to the app
|
|
||||||
await page.goto('/');
|
|
||||||
await page.waitForLoadState('load');
|
|
||||||
await handleLoginScreenIfPresent(page);
|
|
||||||
|
|
||||||
// Should go straight to board view (not welcome) since we have currentProject
|
|
||||||
await expect(page.locator('[data-testid="board-view"]')).toBeVisible({ timeout: 15000 });
|
|
||||||
|
|
||||||
// Wait for settings to load
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Verify that the settings API was called for this project
|
|
||||||
const projectSettingsCalls = settingsApiCalls.filter((call) => call.body.includes(projectPath));
|
|
||||||
|
|
||||||
// Debug: log all API calls if test fails
|
|
||||||
if (projectSettingsCalls.length < 1) {
|
|
||||||
console.log('Total settings API calls:', settingsApiCalls.length);
|
|
||||||
console.log('API calls:', JSON.stringify(settingsApiCalls, null, 2));
|
|
||||||
console.log('Looking for path:', projectPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(projectSettingsCalls.length).toBeGreaterThanOrEqual(1);
|
|
||||||
|
|
||||||
// Verify settings file exists with correct data
|
|
||||||
const loadedSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
||||||
expect(loadedSettings.boardBackground).toBeDefined();
|
|
||||||
expect(loadedSettings.boardBackground.imagePath).toBe(backgroundPath);
|
|
||||||
expect(loadedSettings.boardBackground.cardOpacity).toBe(90);
|
|
||||||
expect(loadedSettings.boardBackground.columnOpacity).toBe(70);
|
|
||||||
|
|
||||||
// Clean up route handlers to avoid "route in flight" errors during teardown
|
|
||||||
await page.unrouteAll({ behavior: 'ignoreErrors' });
|
|
||||||
|
|
||||||
// The test passing means:
|
|
||||||
// 1. The useProjectSettingsLoader hook is working
|
|
||||||
// 2. Settings are loaded when app starts with a currentProject
|
|
||||||
// 3. The API call to /api/settings/project is made correctly
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Page, expect } from '@playwright/test';
|
import { Page, expect } from '@playwright/test';
|
||||||
import { getByTestId, getButtonByText } from './elements';
|
import { getByTestId, getButtonByText } from './elements';
|
||||||
|
import { waitForSplashScreenToDisappear } from './waiting';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the platform-specific modifier key (Meta for Mac, Control for Windows/Linux)
|
* Get the platform-specific modifier key (Meta for Mac, Control for Windows/Linux)
|
||||||
@@ -22,10 +23,10 @@ export async function pressModifierEnter(page: Page): Promise<void> {
|
|||||||
* Waits for the element to be visible before clicking to avoid flaky tests
|
* Waits for the element to be visible before clicking to avoid flaky tests
|
||||||
*/
|
*/
|
||||||
export async function clickElement(page: Page, testId: string): Promise<void> {
|
export async function clickElement(page: Page, testId: string): Promise<void> {
|
||||||
// Splash screen waits are handled by navigation helpers (navigateToContext, navigateToMemory, etc.)
|
// Wait for splash screen to disappear first (safety net)
|
||||||
// before any clickElement calls, so we skip the splash check here to avoid blocking when
|
await waitForSplashScreenToDisappear(page, 5000);
|
||||||
// other fixed overlays (e.g. HeaderActionsPanel backdrop at z-[60]) are present on the page.
|
|
||||||
const element = page.locator(`[data-testid="${testId}"]`);
|
const element = page.locator(`[data-testid="${testId}"]`);
|
||||||
|
// Wait for element to be visible and stable before clicking
|
||||||
await element.waitFor({ state: 'visible', timeout: 10000 });
|
await element.waitFor({ state: 'visible', timeout: 10000 });
|
||||||
await element.click();
|
await element.click();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,16 +54,13 @@ export async function waitForElementHidden(
|
|||||||
*/
|
*/
|
||||||
export async function waitForSplashScreenToDisappear(page: Page, timeout = 5000): Promise<void> {
|
export async function waitForSplashScreenToDisappear(page: Page, timeout = 5000): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Check if splash screen is disabled or already shown (fastest check)
|
// Check if splash screen is shown via sessionStorage first (fastest check)
|
||||||
const splashDisabled = await page.evaluate(() => {
|
const splashShown = await page.evaluate(() => {
|
||||||
return (
|
return sessionStorage.getItem('automaker-splash-shown') === 'true';
|
||||||
localStorage.getItem('automaker-disable-splash') === 'true' ||
|
|
||||||
localStorage.getItem('automaker-splash-shown-session') === 'true'
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// If splash is disabled or already shown, it won't appear, so we're done
|
// If splash is already marked as shown, it won't appear, so we're done
|
||||||
if (splashDisabled) {
|
if (splashShown) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,11 +69,8 @@ export async function waitForSplashScreenToDisappear(page: Page, timeout = 5000)
|
|||||||
// We check for elements that match the splash screen pattern
|
// We check for elements that match the splash screen pattern
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => {
|
() => {
|
||||||
// Check if splash is disabled or already shown
|
// Check if splash is marked as shown in sessionStorage
|
||||||
if (
|
if (sessionStorage.getItem('automaker-splash-shown') === 'true') {
|
||||||
localStorage.getItem('automaker-disable-splash') === 'true' ||
|
|
||||||
localStorage.getItem('automaker-splash-shown-session') === 'true'
|
|
||||||
) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ export async function setupProjectWithPath(page: Page, projectPath: string): Pro
|
|||||||
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
localStorage.setItem('automaker-disable-splash', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
}, projectPath);
|
}, projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,7 +435,7 @@ export async function setupProjectWithPathNoWorktrees(
|
|||||||
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
localStorage.setItem('automaker-disable-splash', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
}, projectPath);
|
}, projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -493,7 +493,7 @@ export async function setupProjectWithStaleWorktree(
|
|||||||
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
localStorage.setItem('automaker-disable-splash', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
}, projectPath);
|
}, projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,12 +22,10 @@ export * from './navigation/views';
|
|||||||
// View-specific utilities
|
// View-specific utilities
|
||||||
export * from './views/board';
|
export * from './views/board';
|
||||||
export * from './views/context';
|
export * from './views/context';
|
||||||
export * from './views/memory';
|
|
||||||
export * from './views/spec-editor';
|
export * from './views/spec-editor';
|
||||||
export * from './views/agent';
|
export * from './views/agent';
|
||||||
export * from './views/settings';
|
export * from './views/settings';
|
||||||
export * from './views/setup';
|
export * from './views/setup';
|
||||||
export * from './views/profiles';
|
|
||||||
|
|
||||||
// Component utilities
|
// Component utilities
|
||||||
export * from './components/dialogs';
|
export * from './components/dialogs';
|
||||||
|
|||||||
@@ -12,12 +12,9 @@ export async function navigateToBoard(page: Page): Promise<void> {
|
|||||||
// Authenticate before navigating
|
// Authenticate before navigating
|
||||||
await authenticateForTests(page);
|
await authenticateForTests(page);
|
||||||
|
|
||||||
// Wait for any pending navigation to complete before starting a new one
|
|
||||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
||||||
await page.waitForTimeout(100);
|
|
||||||
|
|
||||||
// Navigate directly to /board route
|
// Navigate directly to /board route
|
||||||
await page.goto('/board', { waitUntil: 'domcontentloaded' });
|
await page.goto('/board');
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
|
||||||
// Wait for splash screen to disappear (safety net)
|
// Wait for splash screen to disappear (safety net)
|
||||||
await waitForSplashScreenToDisappear(page, 3000);
|
await waitForSplashScreenToDisappear(page, 3000);
|
||||||
@@ -37,13 +34,9 @@ export async function navigateToContext(page: Page): Promise<void> {
|
|||||||
// Authenticate before navigating
|
// Authenticate before navigating
|
||||||
await authenticateForTests(page);
|
await authenticateForTests(page);
|
||||||
|
|
||||||
// Wait for any pending navigation to complete before starting a new one
|
|
||||||
// This prevents race conditions, especially on mobile viewports
|
|
||||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
||||||
await page.waitForTimeout(100);
|
|
||||||
|
|
||||||
// Navigate directly to /context route
|
// Navigate directly to /context route
|
||||||
await page.goto('/context', { waitUntil: 'domcontentloaded' });
|
await page.goto('/context');
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
|
||||||
// Wait for splash screen to disappear (safety net)
|
// Wait for splash screen to disappear (safety net)
|
||||||
await waitForSplashScreenToDisappear(page, 3000);
|
await waitForSplashScreenToDisappear(page, 3000);
|
||||||
@@ -66,14 +59,6 @@ export async function navigateToContext(page: Page): Promise<void> {
|
|||||||
// Wait for the context view to be visible
|
// Wait for the context view to be visible
|
||||||
// Increase timeout to handle slower server startup
|
// Increase timeout to handle slower server startup
|
||||||
await waitForElement(page, 'context-view', { timeout: 15000 });
|
await waitForElement(page, 'context-view', { timeout: 15000 });
|
||||||
|
|
||||||
// On mobile, close the sidebar if open so the header actions trigger is clickable (not covered by backdrop)
|
|
||||||
// Use JavaScript click to avoid force:true hitting the sidebar (z-30) instead of the backdrop (z-20)
|
|
||||||
const backdrop = page.locator('[data-testid="sidebar-backdrop"]');
|
|
||||||
if (await backdrop.isVisible().catch(() => false)) {
|
|
||||||
await backdrop.evaluate((el) => (el as HTMLElement).click());
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,12 +69,9 @@ export async function navigateToSpec(page: Page): Promise<void> {
|
|||||||
// Authenticate before navigating
|
// Authenticate before navigating
|
||||||
await authenticateForTests(page);
|
await authenticateForTests(page);
|
||||||
|
|
||||||
// Wait for any pending navigation to complete before starting a new one
|
|
||||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
||||||
await page.waitForTimeout(100);
|
|
||||||
|
|
||||||
// Navigate directly to /spec route
|
// Navigate directly to /spec route
|
||||||
await page.goto('/spec', { waitUntil: 'domcontentloaded' });
|
await page.goto('/spec');
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
|
||||||
// Wait for splash screen to disappear (safety net)
|
// Wait for splash screen to disappear (safety net)
|
||||||
await waitForSplashScreenToDisappear(page, 3000);
|
await waitForSplashScreenToDisappear(page, 3000);
|
||||||
@@ -123,12 +105,9 @@ export async function navigateToAgent(page: Page): Promise<void> {
|
|||||||
// Authenticate before navigating
|
// Authenticate before navigating
|
||||||
await authenticateForTests(page);
|
await authenticateForTests(page);
|
||||||
|
|
||||||
// Wait for any pending navigation to complete before starting a new one
|
|
||||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
||||||
await page.waitForTimeout(100);
|
|
||||||
|
|
||||||
// Navigate directly to /agent route
|
// Navigate directly to /agent route
|
||||||
await page.goto('/agent', { waitUntil: 'domcontentloaded' });
|
await page.goto('/agent');
|
||||||
|
await page.waitForLoadState('load');
|
||||||
|
|
||||||
// Wait for splash screen to disappear (safety net)
|
// Wait for splash screen to disappear (safety net)
|
||||||
await waitForSplashScreenToDisappear(page, 3000);
|
await waitForSplashScreenToDisappear(page, 3000);
|
||||||
|
|||||||
@@ -1,180 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for project fixture utilities
|
|
||||||
*
|
|
||||||
* Tests for path traversal guard and file operations in test fixtures
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import {
|
|
||||||
createMemoryFileOnDisk,
|
|
||||||
memoryFileExistsOnDisk,
|
|
||||||
resetMemoryDirectory,
|
|
||||||
createContextFileOnDisk,
|
|
||||||
contextFileExistsOnDisk,
|
|
||||||
resetContextDirectory,
|
|
||||||
} from './fixtures';
|
|
||||||
|
|
||||||
test.describe('Memory Fixture Utilities', () => {
|
|
||||||
test.beforeEach(() => {
|
|
||||||
resetMemoryDirectory();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterEach(() => {
|
|
||||||
resetMemoryDirectory();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should create and detect a valid memory file', () => {
|
|
||||||
const filename = 'test-file.md';
|
|
||||||
const content = '# Test Content';
|
|
||||||
|
|
||||||
createMemoryFileOnDisk(filename, content);
|
|
||||||
|
|
||||||
expect(memoryFileExistsOnDisk(filename)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return false for non-existent file', () => {
|
|
||||||
expect(memoryFileExistsOnDisk('non-existent.md')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should reject path traversal attempt with ../', () => {
|
|
||||||
const maliciousFilename = '../../../etc/passwd';
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
createMemoryFileOnDisk(maliciousFilename, 'malicious content');
|
|
||||||
}).toThrow('Invalid memory filename');
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
memoryFileExistsOnDisk(maliciousFilename);
|
|
||||||
}).toThrow('Invalid memory filename');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle Windows-style path traversal attempt ..\\ (platform-dependent)', () => {
|
|
||||||
const maliciousFilename = '..\\..\\..\\windows\\system32\\config';
|
|
||||||
|
|
||||||
// On Unix/macOS, backslash is treated as a literal character in filenames,
|
|
||||||
// not as a path separator, so path.resolve doesn't traverse directories.
|
|
||||||
// This test documents that behavior - the guard works for Unix paths,
|
|
||||||
// but Windows-style backslashes are handled differently per platform.
|
|
||||||
// On macOS/Linux: backslash is a valid filename character
|
|
||||||
// On Windows: would need additional normalization to prevent traversal
|
|
||||||
expect(() => {
|
|
||||||
memoryFileExistsOnDisk(maliciousFilename);
|
|
||||||
}).not.toThrow();
|
|
||||||
|
|
||||||
// The file gets created with backslashes in the name (which is valid on Unix)
|
|
||||||
// but won't escape the directory
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should reject absolute path attempt', () => {
|
|
||||||
const maliciousFilename = '/etc/passwd';
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
createMemoryFileOnDisk(maliciousFilename, 'malicious content');
|
|
||||||
}).toThrow('Invalid memory filename');
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
memoryFileExistsOnDisk(maliciousFilename);
|
|
||||||
}).toThrow('Invalid memory filename');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should accept nested paths within memory directory', () => {
|
|
||||||
// Note: This tests the boundary - if subdirectories are supported,
|
|
||||||
// this should pass; if not, it should throw
|
|
||||||
const nestedFilename = 'subfolder/nested-file.md';
|
|
||||||
|
|
||||||
// Currently, the implementation doesn't create subdirectories,
|
|
||||||
// so this would fail when trying to write. But the path itself
|
|
||||||
// is valid (doesn't escape the memory directory)
|
|
||||||
expect(() => {
|
|
||||||
memoryFileExistsOnDisk(nestedFilename);
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle filenames without extensions', () => {
|
|
||||||
const filename = 'README';
|
|
||||||
|
|
||||||
createMemoryFileOnDisk(filename, 'content without extension');
|
|
||||||
|
|
||||||
expect(memoryFileExistsOnDisk(filename)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle filenames with multiple dots', () => {
|
|
||||||
const filename = 'my.file.name.md';
|
|
||||||
|
|
||||||
createMemoryFileOnDisk(filename, '# Multiple dots');
|
|
||||||
|
|
||||||
expect(memoryFileExistsOnDisk(filename)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Context Fixture Utilities', () => {
|
|
||||||
test.beforeEach(() => {
|
|
||||||
resetContextDirectory();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterEach(() => {
|
|
||||||
resetContextDirectory();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should create and detect a valid context file', () => {
|
|
||||||
const filename = 'test-context.md';
|
|
||||||
const content = '# Test Context Content';
|
|
||||||
|
|
||||||
createContextFileOnDisk(filename, content);
|
|
||||||
|
|
||||||
expect(contextFileExistsOnDisk(filename)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return false for non-existent context file', () => {
|
|
||||||
expect(contextFileExistsOnDisk('non-existent.md')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should reject path traversal attempt with ../ for context files', () => {
|
|
||||||
const maliciousFilename = '../../../etc/passwd';
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
createContextFileOnDisk(maliciousFilename, 'malicious content');
|
|
||||||
}).toThrow('Invalid context filename');
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
contextFileExistsOnDisk(maliciousFilename);
|
|
||||||
}).toThrow('Invalid context filename');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should reject absolute path attempt for context files', () => {
|
|
||||||
const maliciousFilename = '/etc/passwd';
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
createContextFileOnDisk(maliciousFilename, 'malicious content');
|
|
||||||
}).toThrow('Invalid context filename');
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
contextFileExistsOnDisk(maliciousFilename);
|
|
||||||
}).toThrow('Invalid context filename');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should accept nested paths within context directory', () => {
|
|
||||||
const nestedFilename = 'subfolder/nested-file.md';
|
|
||||||
|
|
||||||
// The path itself is valid (doesn't escape the context directory)
|
|
||||||
expect(() => {
|
|
||||||
contextFileExistsOnDisk(nestedFilename);
|
|
||||||
}).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle filenames without extensions for context', () => {
|
|
||||||
const filename = 'README';
|
|
||||||
|
|
||||||
createContextFileOnDisk(filename, 'content without extension');
|
|
||||||
|
|
||||||
expect(contextFileExistsOnDisk(filename)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should handle filenames with multiple dots for context', () => {
|
|
||||||
const filename = 'my.context.file.md';
|
|
||||||
|
|
||||||
createContextFileOnDisk(filename, '# Multiple dots');
|
|
||||||
|
|
||||||
expect(contextFileExistsOnDisk(filename)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -17,7 +17,6 @@ const WORKSPACE_ROOT = getWorkspaceRoot();
|
|||||||
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, 'test/fixtures/projectA');
|
const FIXTURE_PATH = path.join(WORKSPACE_ROOT, 'test/fixtures/projectA');
|
||||||
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, '.automaker/app_spec.txt');
|
const SPEC_FILE_PATH = path.join(FIXTURE_PATH, '.automaker/app_spec.txt');
|
||||||
const CONTEXT_PATH = path.join(FIXTURE_PATH, '.automaker/context');
|
const CONTEXT_PATH = path.join(FIXTURE_PATH, '.automaker/context');
|
||||||
const MEMORY_PATH = path.join(FIXTURE_PATH, '.automaker/memory');
|
|
||||||
|
|
||||||
// Original spec content for resetting between tests
|
// Original spec content for resetting between tests
|
||||||
const ORIGINAL_SPEC_CONTENT = `<app_spec>
|
const ORIGINAL_SPEC_CONTENT = `<app_spec>
|
||||||
@@ -51,53 +50,11 @@ export function resetContextDirectory(): void {
|
|||||||
fs.mkdirSync(CONTEXT_PATH, { recursive: true });
|
fs.mkdirSync(CONTEXT_PATH, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Reset the memory directory to empty state
|
|
||||||
*/
|
|
||||||
export function resetMemoryDirectory(): void {
|
|
||||||
if (fs.existsSync(MEMORY_PATH)) {
|
|
||||||
fs.rmSync(MEMORY_PATH, { recursive: true });
|
|
||||||
}
|
|
||||||
fs.mkdirSync(MEMORY_PATH, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve and validate a context fixture path to prevent path traversal
|
|
||||||
*/
|
|
||||||
function resolveContextFixturePath(filename: string): string {
|
|
||||||
const resolved = path.resolve(CONTEXT_PATH, filename);
|
|
||||||
const base = path.resolve(CONTEXT_PATH) + path.sep;
|
|
||||||
if (!resolved.startsWith(base)) {
|
|
||||||
throw new Error(`Invalid context filename: ${filename}`);
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a context file directly on disk (for test setup)
|
* Create a context file directly on disk (for test setup)
|
||||||
*/
|
*/
|
||||||
export function createContextFileOnDisk(filename: string, content: string): void {
|
export function createContextFileOnDisk(filename: string, content: string): void {
|
||||||
const filePath = resolveContextFixturePath(filename);
|
const filePath = path.join(CONTEXT_PATH, filename);
|
||||||
fs.writeFileSync(filePath, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve and validate a memory fixture path to prevent path traversal
|
|
||||||
*/
|
|
||||||
function resolveMemoryFixturePath(filename: string): string {
|
|
||||||
const resolved = path.resolve(MEMORY_PATH, filename);
|
|
||||||
const base = path.resolve(MEMORY_PATH) + path.sep;
|
|
||||||
if (!resolved.startsWith(base)) {
|
|
||||||
throw new Error(`Invalid memory filename: ${filename}`);
|
|
||||||
}
|
|
||||||
return resolved;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a memory file directly on disk (for test setup)
|
|
||||||
*/
|
|
||||||
export function createMemoryFileOnDisk(filename: string, content: string): void {
|
|
||||||
const filePath = resolveMemoryFixturePath(filename);
|
|
||||||
fs.writeFileSync(filePath, content);
|
fs.writeFileSync(filePath, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,15 +62,7 @@ export function createMemoryFileOnDisk(filename: string, content: string): void
|
|||||||
* Check if a context file exists on disk
|
* Check if a context file exists on disk
|
||||||
*/
|
*/
|
||||||
export function contextFileExistsOnDisk(filename: string): boolean {
|
export function contextFileExistsOnDisk(filename: string): boolean {
|
||||||
const filePath = resolveContextFixturePath(filename);
|
const filePath = path.join(CONTEXT_PATH, filename);
|
||||||
return fs.existsSync(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a memory file exists on disk
|
|
||||||
*/
|
|
||||||
export function memoryFileExistsOnDisk(filename: string): boolean {
|
|
||||||
const filePath = resolveMemoryFixturePath(filename);
|
|
||||||
return fs.existsSync(filePath);
|
return fs.existsSync(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,29 +112,8 @@ export async function setupProjectWithFixture(
|
|||||||
};
|
};
|
||||||
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
// Set settings cache so the fast-hydrate path uses our fixture project.
|
|
||||||
// Without this, a stale settings cache from a previous test can override
|
|
||||||
// the project we just set in automaker-storage.
|
|
||||||
const settingsCache = {
|
|
||||||
setupComplete: true,
|
|
||||||
isFirstRun: false,
|
|
||||||
projects: [
|
|
||||||
{
|
|
||||||
id: mockProject.id,
|
|
||||||
name: mockProject.name,
|
|
||||||
path: mockProject.path,
|
|
||||||
lastOpened: mockProject.lastOpened,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
currentProjectId: mockProject.id,
|
|
||||||
theme: 'dark',
|
|
||||||
sidebarOpen: true,
|
|
||||||
maxConcurrency: 3,
|
|
||||||
};
|
|
||||||
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
localStorage.setItem('automaker-disable-splash', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
}, projectPath);
|
}, projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,14 +123,3 @@ export async function setupProjectWithFixture(
|
|||||||
export function getFixturePath(): string {
|
export function getFixturePath(): string {
|
||||||
return FIXTURE_PATH;
|
return FIXTURE_PATH;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Set up a mock project with the fixture path (for profile/settings tests that need a project).
|
|
||||||
* Options such as customProfilesCount are reserved for future use (e.g. mocking server profile state).
|
|
||||||
*/
|
|
||||||
export async function setupMockProjectWithProfiles(
|
|
||||||
page: Page,
|
|
||||||
_options?: { customProfilesCount?: number }
|
|
||||||
): Promise<void> {
|
|
||||||
await setupProjectWithFixture(page, FIXTURE_PATH);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -84,9 +84,6 @@ export async function setupWelcomeView(
|
|||||||
setupComplete: true,
|
setupComplete: true,
|
||||||
isFirstRun: false,
|
isFirstRun: false,
|
||||||
projects: opts?.recentProjects || [],
|
projects: opts?.recentProjects || [],
|
||||||
// Explicitly set currentProjectId to null so the fast-hydrate path
|
|
||||||
// does not restore a stale project from a previous test.
|
|
||||||
currentProjectId: null,
|
|
||||||
theme: 'dark',
|
theme: 'dark',
|
||||||
sidebarOpen: true,
|
sidebarOpen: true,
|
||||||
maxConcurrency: 3,
|
maxConcurrency: 3,
|
||||||
@@ -106,7 +103,7 @@ export async function setupWelcomeView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
localStorage.setItem('automaker-disable-splash', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
|
|
||||||
// Set up a mechanism to keep currentProject null even after settings hydration
|
// Set up a mechanism to keep currentProject null even after settings hydration
|
||||||
// Settings API might restore a project, so we override it after hydration
|
// Settings API might restore a project, so we override it after hydration
|
||||||
@@ -229,7 +226,7 @@ export async function setupRealProject(
|
|||||||
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
localStorage.setItem('automaker-disable-splash', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
},
|
},
|
||||||
{ path: projectPath, name: projectName, opts: options, versions: STORE_VERSIONS }
|
{ path: projectPath, name: projectName, opts: options, versions: STORE_VERSIONS }
|
||||||
);
|
);
|
||||||
@@ -294,7 +291,7 @@ export async function setupMockProject(page: Page): Promise<void> {
|
|||||||
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
localStorage.setItem('automaker-disable-splash', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
}, STORE_VERSIONS);
|
}, STORE_VERSIONS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +423,7 @@ export async function setupMockProjectAtConcurrencyLimit(
|
|||||||
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
localStorage.setItem('automaker-disable-splash', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
},
|
},
|
||||||
{ maxConcurrency, runningTasks, versions: STORE_VERSIONS }
|
{ maxConcurrency, runningTasks, versions: STORE_VERSIONS }
|
||||||
);
|
);
|
||||||
@@ -508,7 +505,7 @@ export async function setupMockProjectWithFeatures(
|
|||||||
(window as { __mockFeatures?: unknown[] }).__mockFeatures = mockFeatures;
|
(window as { __mockFeatures?: unknown[] }).__mockFeatures = mockFeatures;
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
localStorage.setItem('automaker-disable-splash', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
},
|
},
|
||||||
{ opts: options, versions: STORE_VERSIONS }
|
{ opts: options, versions: STORE_VERSIONS }
|
||||||
);
|
);
|
||||||
@@ -580,7 +577,7 @@ export async function setupMockProjectWithContextFile(
|
|||||||
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
localStorage.setItem('automaker-disable-splash', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
|
|
||||||
// Set up mock file system with a context file for the feature
|
// Set up mock file system with a context file for the feature
|
||||||
// This will be used by the mock electron API
|
// This will be used by the mock electron API
|
||||||
@@ -772,7 +769,7 @@ export async function setupEmptyLocalStorage(page: Page): Promise<void> {
|
|||||||
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
localStorage.setItem('automaker-disable-splash', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
}, STORE_VERSIONS);
|
}, STORE_VERSIONS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,7 +832,7 @@ export async function setupMockProjectsWithoutCurrent(page: Page): Promise<void>
|
|||||||
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
localStorage.setItem('automaker-disable-splash', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
}, STORE_VERSIONS);
|
}, STORE_VERSIONS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,7 +910,7 @@ export async function setupMockProjectWithSkipTestsFeatures(
|
|||||||
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
localStorage.setItem('automaker-disable-splash', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
},
|
},
|
||||||
{ opts: options, versions: STORE_VERSIONS }
|
{ opts: options, versions: STORE_VERSIONS }
|
||||||
);
|
);
|
||||||
@@ -988,7 +985,7 @@ export async function setupMockMultipleProjects(
|
|||||||
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
localStorage.setItem('automaker-disable-splash', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
},
|
},
|
||||||
{ count: projectCount, versions: STORE_VERSIONS }
|
{ count: projectCount, versions: STORE_VERSIONS }
|
||||||
);
|
);
|
||||||
@@ -1059,7 +1056,7 @@ export async function setupMockProjectWithAgentOutput(
|
|||||||
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
localStorage.setItem('automaker-disable-splash', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
|
|
||||||
// Set up mock file system with output content for the feature
|
// Set up mock file system with output content for the feature
|
||||||
// Now uses features/{id}/agent-output.md path
|
// Now uses features/{id}/agent-output.md path
|
||||||
@@ -1218,7 +1215,7 @@ export async function setupFirstRun(page: Page): Promise<void> {
|
|||||||
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
localStorage.setItem('automaker-settings-cache', JSON.stringify(settingsCache));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
localStorage.setItem('automaker-disable-splash', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
}, STORE_VERSIONS);
|
}, STORE_VERSIONS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1241,6 +1238,6 @@ export async function setupComplete(page: Page): Promise<void> {
|
|||||||
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
localStorage.setItem('automaker-setup', JSON.stringify(setupState));
|
||||||
|
|
||||||
// Disable splash screen in tests
|
// Disable splash screen in tests
|
||||||
localStorage.setItem('automaker-disable-splash', 'true');
|
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||||
}, STORE_VERSIONS);
|
}, STORE_VERSIONS);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,22 +97,10 @@ export async function deleteSelectedContextFile(page: Page): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export async function saveContextFile(page: Page): Promise<void> {
|
export async function saveContextFile(page: Page): Promise<void> {
|
||||||
await clickElement(page, 'save-context-file');
|
await clickElement(page, 'save-context-file');
|
||||||
// Wait for save to complete across desktop/mobile variants
|
// Wait for save to complete (button shows "Saved")
|
||||||
// On desktop: button text shows "Saved"
|
|
||||||
// On mobile: icon-only button uses aria-label or title
|
|
||||||
await page.waitForFunction(
|
await page.waitForFunction(
|
||||||
() => {
|
() =>
|
||||||
const btn = document.querySelector('[data-testid="save-context-file"]');
|
document.querySelector('[data-testid="save-context-file"]')?.textContent?.includes('Saved'),
|
||||||
if (!btn) return false;
|
|
||||||
const stateText = [
|
|
||||||
btn.textContent ?? '',
|
|
||||||
btn.getAttribute('aria-label') ?? '',
|
|
||||||
btn.getAttribute('title') ?? '',
|
|
||||||
]
|
|
||||||
.join(' ')
|
|
||||||
.toLowerCase();
|
|
||||||
return stateText.includes('saved');
|
|
||||||
},
|
|
||||||
{ timeout: 5000 }
|
{ timeout: 5000 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -150,16 +138,13 @@ export async function selectContextFile(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const fileButton = await getByTestId(page, `context-file-${filename}`);
|
const fileButton = await getByTestId(page, `context-file-${filename}`);
|
||||||
|
|
||||||
// Retry click + wait for content panel to handle timing issues
|
// Retry click + wait for delete button to handle timing issues
|
||||||
// Note: On mobile, delete button is hidden, so we wait for content panel instead
|
|
||||||
await expect(async () => {
|
await expect(async () => {
|
||||||
// Use JavaScript click to ensure React onClick handler fires
|
// Use JavaScript click to ensure React onClick handler fires
|
||||||
await fileButton.evaluate((el) => (el as HTMLButtonElement).click());
|
await fileButton.evaluate((el) => (el as HTMLButtonElement).click());
|
||||||
// Wait for content to appear (editor, preview, or image)
|
// Wait for the file to be selected (toolbar with delete button becomes visible)
|
||||||
const contentLocator = page.locator(
|
const deleteButton = await getByTestId(page, 'delete-context-file');
|
||||||
'[data-testid="context-editor"], [data-testid="markdown-preview"], [data-testid="image-preview"]'
|
await expect(deleteButton).toBeVisible();
|
||||||
);
|
|
||||||
await expect(contentLocator).toBeVisible();
|
|
||||||
}).toPass({ timeout, intervals: [500, 1000, 2000] });
|
}).toPass({ timeout, intervals: [500, 1000, 2000] });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
import { Page, Locator } from '@playwright/test';
|
|
||||||
import { clickElement, fillInput, handleLoginScreenIfPresent } from '../core/interactions';
|
|
||||||
import {
|
|
||||||
waitForElement,
|
|
||||||
waitForElementHidden,
|
|
||||||
waitForSplashScreenToDisappear,
|
|
||||||
} from '../core/waiting';
|
|
||||||
import { getByTestId } from '../core/elements';
|
|
||||||
import { expect } from '@playwright/test';
|
|
||||||
import { authenticateForTests } from '../api/client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the memory file list element
|
|
||||||
*/
|
|
||||||
export async function getMemoryFileList(page: Page): Promise<Locator> {
|
|
||||||
return page.locator('[data-testid="memory-file-list"]');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Click on a memory file in the list
|
|
||||||
*/
|
|
||||||
export async function clickMemoryFile(page: Page, fileName: string): Promise<void> {
|
|
||||||
const fileButton = page.locator(`[data-testid="memory-file-${fileName}"]`);
|
|
||||||
await fileButton.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the memory editor element
|
|
||||||
*/
|
|
||||||
export async function getMemoryEditor(page: Page): Promise<Locator> {
|
|
||||||
return page.locator('[data-testid="memory-editor"]');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the memory editor content
|
|
||||||
*/
|
|
||||||
export async function getMemoryEditorContent(page: Page): Promise<string> {
|
|
||||||
const editor = await getByTestId(page, 'memory-editor');
|
|
||||||
return await editor.inputValue();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the memory editor content
|
|
||||||
*/
|
|
||||||
export async function setMemoryEditorContent(page: Page, content: string): Promise<void> {
|
|
||||||
const editor = await getByTestId(page, 'memory-editor');
|
|
||||||
await editor.fill(content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open the create memory file dialog
|
|
||||||
*/
|
|
||||||
export async function openCreateMemoryDialog(page: Page): Promise<void> {
|
|
||||||
await clickElement(page, 'create-memory-button');
|
|
||||||
await waitForElement(page, 'create-memory-dialog');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a memory file via the UI
|
|
||||||
*/
|
|
||||||
export async function createMemoryFile(
|
|
||||||
page: Page,
|
|
||||||
filename: string,
|
|
||||||
content: string
|
|
||||||
): Promise<void> {
|
|
||||||
await openCreateMemoryDialog(page);
|
|
||||||
await fillInput(page, 'new-memory-name', filename);
|
|
||||||
await fillInput(page, 'new-memory-content', content);
|
|
||||||
await clickElement(page, 'confirm-create-memory');
|
|
||||||
await waitForElementHidden(page, 'create-memory-dialog');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a memory file via the UI (must be selected first)
|
|
||||||
*/
|
|
||||||
export async function deleteSelectedMemoryFile(page: Page): Promise<void> {
|
|
||||||
await clickElement(page, 'delete-memory-file');
|
|
||||||
await waitForElement(page, 'delete-memory-dialog');
|
|
||||||
await clickElement(page, 'confirm-delete-memory');
|
|
||||||
await waitForElementHidden(page, 'delete-memory-dialog');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save the current memory file
|
|
||||||
*/
|
|
||||||
export async function saveMemoryFile(page: Page): Promise<void> {
|
|
||||||
await clickElement(page, 'save-memory-file');
|
|
||||||
// Wait for save to complete across desktop/mobile variants
|
|
||||||
// On desktop: button text shows "Saved"
|
|
||||||
// On mobile: icon-only button uses aria-label or title
|
|
||||||
await page.waitForFunction(
|
|
||||||
() => {
|
|
||||||
const btn = document.querySelector('[data-testid="save-memory-file"]');
|
|
||||||
if (!btn) return false;
|
|
||||||
const stateText = [
|
|
||||||
btn.textContent ?? '',
|
|
||||||
btn.getAttribute('aria-label') ?? '',
|
|
||||||
btn.getAttribute('title') ?? '',
|
|
||||||
]
|
|
||||||
.join(' ')
|
|
||||||
.toLowerCase();
|
|
||||||
return stateText.includes('saved');
|
|
||||||
},
|
|
||||||
{ timeout: 5000 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toggle markdown preview mode
|
|
||||||
*/
|
|
||||||
export async function toggleMemoryPreviewMode(page: Page): Promise<void> {
|
|
||||||
await clickElement(page, 'toggle-preview-mode');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for a specific file to appear in the memory file list
|
|
||||||
* Uses retry mechanism to handle race conditions with API/UI updates
|
|
||||||
*/
|
|
||||||
export async function waitForMemoryFile(
|
|
||||||
page: Page,
|
|
||||||
filename: string,
|
|
||||||
timeout: number = 15000
|
|
||||||
): Promise<void> {
|
|
||||||
await expect(async () => {
|
|
||||||
const locator = page.locator(`[data-testid="memory-file-${filename}"]`);
|
|
||||||
await expect(locator).toBeVisible();
|
|
||||||
}).toPass({ timeout, intervals: [500, 1000, 2000] });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Click a file in the list and wait for it to be selected (toolbar visible)
|
|
||||||
* Uses retry mechanism to handle race conditions where element is visible but not yet interactive
|
|
||||||
*/
|
|
||||||
export async function selectMemoryFile(
|
|
||||||
page: Page,
|
|
||||||
filename: string,
|
|
||||||
timeout: number = 15000
|
|
||||||
): Promise<void> {
|
|
||||||
const fileButton = await getByTestId(page, `memory-file-${filename}`);
|
|
||||||
|
|
||||||
// Retry click + wait for content panel to handle timing issues
|
|
||||||
// Note: On mobile, delete button is hidden, so we wait for content panel instead
|
|
||||||
await expect(async () => {
|
|
||||||
// Use JavaScript click to ensure React onClick handler fires
|
|
||||||
await fileButton.evaluate((el) => (el as HTMLButtonElement).click());
|
|
||||||
// Wait for content to appear (editor or preview)
|
|
||||||
const contentLocator = page.locator(
|
|
||||||
'[data-testid="memory-editor"], [data-testid="markdown-preview"]'
|
|
||||||
);
|
|
||||||
await expect(contentLocator).toBeVisible();
|
|
||||||
}).toPass({ timeout, intervals: [500, 1000, 2000] });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for file content panel to load (either editor or preview)
|
|
||||||
* Uses retry mechanism to handle race conditions with file selection
|
|
||||||
*/
|
|
||||||
export async function waitForMemoryContentToLoad(
|
|
||||||
page: Page,
|
|
||||||
timeout: number = 15000
|
|
||||||
): Promise<void> {
|
|
||||||
await expect(async () => {
|
|
||||||
const contentLocator = page.locator(
|
|
||||||
'[data-testid="memory-editor"], [data-testid="markdown-preview"]'
|
|
||||||
);
|
|
||||||
await expect(contentLocator).toBeVisible();
|
|
||||||
}).toPass({ timeout, intervals: [500, 1000, 2000] });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Switch from preview mode to edit mode for memory files
|
|
||||||
* Memory files open in preview mode by default, this helper switches to edit mode
|
|
||||||
*/
|
|
||||||
export async function switchMemoryToEditMode(page: Page): Promise<void> {
|
|
||||||
// First wait for content to load
|
|
||||||
await waitForMemoryContentToLoad(page);
|
|
||||||
|
|
||||||
const markdownPreview = await getByTestId(page, 'markdown-preview');
|
|
||||||
const isPreview = await markdownPreview.isVisible().catch(() => false);
|
|
||||||
|
|
||||||
if (isPreview) {
|
|
||||||
await clickElement(page, 'toggle-preview-mode');
|
|
||||||
await page.waitForSelector('[data-testid="memory-editor"]', {
|
|
||||||
timeout: 5000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to the memory view
|
|
||||||
* Note: Navigates directly to /memory since index route shows WelcomeView
|
|
||||||
*/
|
|
||||||
export async function navigateToMemory(page: Page): Promise<void> {
|
|
||||||
// Authenticate before navigating (same pattern as navigateToContext / navigateToBoard)
|
|
||||||
await authenticateForTests(page);
|
|
||||||
|
|
||||||
// Wait for any pending navigation to complete before starting a new one
|
|
||||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
||||||
await page.waitForTimeout(100);
|
|
||||||
|
|
||||||
// Navigate directly to /memory route
|
|
||||||
await page.goto('/memory', { waitUntil: 'domcontentloaded' });
|
|
||||||
|
|
||||||
// Wait for splash screen to disappear (safety net)
|
|
||||||
await waitForSplashScreenToDisappear(page, 3000);
|
|
||||||
|
|
||||||
// Handle login redirect if needed (e.g. when redirected to /logged-out)
|
|
||||||
await handleLoginScreenIfPresent(page);
|
|
||||||
|
|
||||||
// Wait for loading to complete (if present)
|
|
||||||
const loadingElement = page.locator('[data-testid="memory-view-loading"]');
|
|
||||||
try {
|
|
||||||
const loadingVisible = await loadingElement.isVisible({ timeout: 2000 });
|
|
||||||
if (loadingVisible) {
|
|
||||||
// Wait for loading to disappear (memory view will appear)
|
|
||||||
await loadingElement.waitFor({ state: 'hidden', timeout: 10000 });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Loading element not found or already hidden, continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for the memory view to be visible
|
|
||||||
await waitForElement(page, 'memory-view', { timeout: 15000 });
|
|
||||||
|
|
||||||
// On mobile, close the sidebar if open so the header actions trigger is clickable (not covered by backdrop)
|
|
||||||
// Use JavaScript click to avoid force:true hitting the sidebar (z-30) instead of the backdrop (z-20)
|
|
||||||
const backdrop = page.locator('[data-testid="sidebar-backdrop"]');
|
|
||||||
if (await backdrop.isVisible().catch(() => false)) {
|
|
||||||
await backdrop.evaluate((el) => (el as HTMLElement).click());
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure the header (and actions panel trigger on mobile) is interactive
|
|
||||||
await page
|
|
||||||
.locator('[data-testid="header-actions-panel-trigger"]')
|
|
||||||
.waitFor({ state: 'visible', timeout: 5000 })
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
@@ -1,522 +0,0 @@
|
|||||||
import { Page, Locator } from '@playwright/test';
|
|
||||||
import { clickElement, fillInput } from '../core/interactions';
|
|
||||||
import { waitForElement, waitForElementHidden } from '../core/waiting';
|
|
||||||
import { getByTestId } from '../core/elements';
|
|
||||||
import { navigateToView } from '../navigation/views';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Navigate to the profiles view
|
|
||||||
*/
|
|
||||||
export async function navigateToProfiles(page: Page): Promise<void> {
|
|
||||||
// Click the profiles navigation button
|
|
||||||
await navigateToView(page, 'profiles');
|
|
||||||
|
|
||||||
// Wait for profiles view to be visible
|
|
||||||
await page.waitForSelector('[data-testid="profiles-view"]', {
|
|
||||||
state: 'visible',
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Profile List Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a specific profile card by ID
|
|
||||||
*/
|
|
||||||
export async function getProfileCard(page: Page, profileId: string): Promise<Locator> {
|
|
||||||
return getByTestId(page, `profile-card-${profileId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all profile cards (both built-in and custom)
|
|
||||||
*/
|
|
||||||
export async function getProfileCards(page: Page): Promise<Locator> {
|
|
||||||
return page.locator('[data-testid^="profile-card-"]');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get only custom profile cards
|
|
||||||
*/
|
|
||||||
export async function getCustomProfiles(page: Page): Promise<Locator> {
|
|
||||||
// Custom profiles don't have the "Built-in" badge
|
|
||||||
return page.locator('[data-testid^="profile-card-"]').filter({
|
|
||||||
hasNot: page.locator('text="Built-in"'),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get only built-in profile cards
|
|
||||||
*/
|
|
||||||
export async function getBuiltInProfiles(page: Page): Promise<Locator> {
|
|
||||||
// Built-in profiles have the lock icon and "Built-in" text
|
|
||||||
return page.locator('[data-testid^="profile-card-"]:has-text("Built-in")');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count the number of custom profiles
|
|
||||||
*/
|
|
||||||
export async function countCustomProfiles(page: Page): Promise<number> {
|
|
||||||
const customProfiles = await getCustomProfiles(page);
|
|
||||||
return customProfiles.count();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Count the number of built-in profiles
|
|
||||||
*/
|
|
||||||
export async function countBuiltInProfiles(page: Page): Promise<number> {
|
|
||||||
const builtInProfiles = await getBuiltInProfiles(page);
|
|
||||||
return await builtInProfiles.count();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all custom profile IDs
|
|
||||||
*/
|
|
||||||
export async function getCustomProfileIds(page: Page): Promise<string[]> {
|
|
||||||
const allCards = await page.locator('[data-testid^="profile-card-"]').all();
|
|
||||||
const customIds: string[] = [];
|
|
||||||
|
|
||||||
for (const card of allCards) {
|
|
||||||
const builtInText = card.locator('text="Built-in"');
|
|
||||||
const isBuiltIn = (await builtInText.count()) > 0;
|
|
||||||
if (!isBuiltIn) {
|
|
||||||
const testId = await card.getAttribute('data-testid');
|
|
||||||
if (testId) {
|
|
||||||
// Extract ID from "profile-card-{id}"
|
|
||||||
const profileId = testId.replace('profile-card-', '');
|
|
||||||
customIds.push(profileId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return customIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the first custom profile ID (useful after creating a profile)
|
|
||||||
*/
|
|
||||||
export async function getFirstCustomProfileId(page: Page): Promise<string | null> {
|
|
||||||
const ids = await getCustomProfileIds(page);
|
|
||||||
return ids.length > 0 ? ids[0] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CRUD Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Click the "New Profile" button in the header
|
|
||||||
*/
|
|
||||||
export async function clickNewProfileButton(page: Page): Promise<void> {
|
|
||||||
await clickElement(page, 'add-profile-button');
|
|
||||||
await waitForElement(page, 'add-profile-dialog');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Click the empty state card to create a new profile
|
|
||||||
*/
|
|
||||||
export async function clickEmptyState(page: Page): Promise<void> {
|
|
||||||
const emptyState = page.locator(
|
|
||||||
'.group.rounded-xl.border.border-dashed[class*="cursor-pointer"]'
|
|
||||||
);
|
|
||||||
await emptyState.click();
|
|
||||||
await waitForElement(page, 'add-profile-dialog');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fill the profile form with data
|
|
||||||
*/
|
|
||||||
export async function fillProfileForm(
|
|
||||||
page: Page,
|
|
||||||
data: {
|
|
||||||
name?: string;
|
|
||||||
description?: string;
|
|
||||||
icon?: string;
|
|
||||||
model?: string;
|
|
||||||
thinkingLevel?: string;
|
|
||||||
}
|
|
||||||
): Promise<void> {
|
|
||||||
if (data.name !== undefined) {
|
|
||||||
await fillProfileName(page, data.name);
|
|
||||||
}
|
|
||||||
if (data.description !== undefined) {
|
|
||||||
await fillProfileDescription(page, data.description);
|
|
||||||
}
|
|
||||||
if (data.icon !== undefined) {
|
|
||||||
await selectIcon(page, data.icon);
|
|
||||||
}
|
|
||||||
if (data.model !== undefined) {
|
|
||||||
await selectModel(page, data.model);
|
|
||||||
}
|
|
||||||
if (data.thinkingLevel !== undefined) {
|
|
||||||
await selectThinkingLevel(page, data.thinkingLevel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Click the save button to create/update a profile
|
|
||||||
*/
|
|
||||||
export async function saveProfile(page: Page): Promise<void> {
|
|
||||||
await clickElement(page, 'save-profile-button');
|
|
||||||
// Wait for dialog to close
|
|
||||||
await waitForElementHidden(page, 'add-profile-dialog').catch(() => {});
|
|
||||||
await waitForElementHidden(page, 'edit-profile-dialog').catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Click the cancel button in the profile dialog
|
|
||||||
*/
|
|
||||||
export async function cancelProfileDialog(page: Page): Promise<void> {
|
|
||||||
// Look for cancel button in dialog footer
|
|
||||||
const cancelButton = page.locator('button:has-text("Cancel")');
|
|
||||||
await cancelButton.click();
|
|
||||||
// Wait for dialog to close
|
|
||||||
await waitForElementHidden(page, 'add-profile-dialog').catch(() => {});
|
|
||||||
await waitForElementHidden(page, 'edit-profile-dialog').catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Click the edit button for a specific profile
|
|
||||||
*/
|
|
||||||
export async function clickEditProfile(page: Page, profileId: string): Promise<void> {
|
|
||||||
await clickElement(page, `edit-profile-${profileId}`);
|
|
||||||
await waitForElement(page, 'edit-profile-dialog');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Click the delete button for a specific profile
|
|
||||||
*/
|
|
||||||
export async function clickDeleteProfile(page: Page, profileId: string): Promise<void> {
|
|
||||||
await clickElement(page, `delete-profile-${profileId}`);
|
|
||||||
await waitForElement(page, 'delete-profile-confirm-dialog');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Confirm profile deletion in the dialog
|
|
||||||
*/
|
|
||||||
export async function confirmDeleteProfile(page: Page): Promise<void> {
|
|
||||||
await clickElement(page, 'confirm-delete-profile-button');
|
|
||||||
await waitForElementHidden(page, 'delete-profile-confirm-dialog');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cancel profile deletion
|
|
||||||
*/
|
|
||||||
export async function cancelDeleteProfile(page: Page): Promise<void> {
|
|
||||||
await clickElement(page, 'cancel-delete-button');
|
|
||||||
await waitForElementHidden(page, 'delete-profile-confirm-dialog');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Form Field Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fill the profile name field
|
|
||||||
*/
|
|
||||||
export async function fillProfileName(page: Page, name: string): Promise<void> {
|
|
||||||
await fillInput(page, 'profile-name-input', name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fill the profile description field
|
|
||||||
*/
|
|
||||||
export async function fillProfileDescription(page: Page, description: string): Promise<void> {
|
|
||||||
await fillInput(page, 'profile-description-input', description);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Select an icon for the profile
|
|
||||||
* @param iconName - Name of the icon: Brain, Zap, Scale, Cpu, Rocket, Sparkles
|
|
||||||
*/
|
|
||||||
export async function selectIcon(page: Page, iconName: string): Promise<void> {
|
|
||||||
await clickElement(page, `icon-select-${iconName}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Select a model for the profile
|
|
||||||
* @param modelId - Model ID: haiku, sonnet, opus
|
|
||||||
*/
|
|
||||||
export async function selectModel(page: Page, modelId: string): Promise<void> {
|
|
||||||
await clickElement(page, `model-select-${modelId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Select a thinking level for the profile
|
|
||||||
* @param level - Thinking level: none, low, medium, high, ultrathink
|
|
||||||
*/
|
|
||||||
export async function selectThinkingLevel(page: Page, level: string): Promise<void> {
|
|
||||||
await clickElement(page, `thinking-select-${level}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the currently selected icon
|
|
||||||
*/
|
|
||||||
export async function getSelectedIcon(page: Page): Promise<string | null> {
|
|
||||||
// Find the icon button with primary background
|
|
||||||
const selectedIcon = page.locator('[data-testid^="icon-select-"][class*="bg-primary"]');
|
|
||||||
const testId = await selectedIcon.getAttribute('data-testid');
|
|
||||||
return testId ? testId.replace('icon-select-', '') : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the currently selected model
|
|
||||||
*/
|
|
||||||
export async function getSelectedModel(page: Page): Promise<string | null> {
|
|
||||||
// Find the model button with primary background
|
|
||||||
const selectedModel = page.locator('[data-testid^="model-select-"][class*="bg-primary"]');
|
|
||||||
const testId = await selectedModel.getAttribute('data-testid');
|
|
||||||
return testId ? testId.replace('model-select-', '') : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the currently selected thinking level
|
|
||||||
*/
|
|
||||||
export async function getSelectedThinkingLevel(page: Page): Promise<string | null> {
|
|
||||||
// Find the thinking level button with amber background
|
|
||||||
const selectedLevel = page.locator('[data-testid^="thinking-select-"][class*="bg-amber-500"]');
|
|
||||||
const testId = await selectedLevel.getAttribute('data-testid');
|
|
||||||
return testId ? testId.replace('thinking-select-', '') : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Dialog Operations
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the add profile dialog is open
|
|
||||||
*/
|
|
||||||
export async function isAddProfileDialogOpen(page: Page): Promise<boolean> {
|
|
||||||
const dialog = await getByTestId(page, 'add-profile-dialog');
|
|
||||||
return await dialog.isVisible().catch(() => false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the edit profile dialog is open
|
|
||||||
*/
|
|
||||||
export async function isEditProfileDialogOpen(page: Page): Promise<boolean> {
|
|
||||||
const dialog = await getByTestId(page, 'edit-profile-dialog');
|
|
||||||
return await dialog.isVisible().catch(() => false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the delete confirmation dialog is open
|
|
||||||
*/
|
|
||||||
export async function isDeleteConfirmDialogOpen(page: Page): Promise<boolean> {
|
|
||||||
const dialog = await getByTestId(page, 'delete-profile-confirm-dialog');
|
|
||||||
return await dialog.isVisible().catch(() => false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wait for any profile dialog to close
|
|
||||||
* This ensures all dialog animations complete before proceeding
|
|
||||||
*/
|
|
||||||
export async function waitForDialogClose(page: Page): Promise<void> {
|
|
||||||
// Wait for all profile dialogs to be hidden
|
|
||||||
await Promise.all([
|
|
||||||
waitForElementHidden(page, 'add-profile-dialog').catch(() => {}),
|
|
||||||
waitForElementHidden(page, 'edit-profile-dialog').catch(() => {}),
|
|
||||||
waitForElementHidden(page, 'delete-profile-confirm-dialog').catch(() => {}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Also wait for any Radix dialog overlay to be removed (handles animation)
|
|
||||||
await page
|
|
||||||
.locator('[data-radix-dialog-overlay]')
|
|
||||||
.waitFor({ state: 'hidden', timeout: 2000 })
|
|
||||||
.catch(() => {
|
|
||||||
// Overlay may not exist
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Profile Card Inspection
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the profile name from a card
|
|
||||||
*/
|
|
||||||
export async function getProfileName(page: Page, profileId: string): Promise<string> {
|
|
||||||
const card = await getProfileCard(page, profileId);
|
|
||||||
const nameElement = card.locator('h3');
|
|
||||||
return await nameElement.textContent().then((text) => text?.trim() || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the profile description from a card
|
|
||||||
*/
|
|
||||||
export async function getProfileDescription(page: Page, profileId: string): Promise<string> {
|
|
||||||
const card = await getProfileCard(page, profileId);
|
|
||||||
const descElement = card.locator('p').first();
|
|
||||||
return await descElement.textContent().then((text) => text?.trim() || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the profile model badge text from a card
|
|
||||||
*/
|
|
||||||
export async function getProfileModel(page: Page, profileId: string): Promise<string> {
|
|
||||||
const card = await getProfileCard(page, profileId);
|
|
||||||
const modelBadge = card.locator(
|
|
||||||
'span[class*="border-primary"]:has-text("haiku"), span[class*="border-primary"]:has-text("sonnet"), span[class*="border-primary"]:has-text("opus")'
|
|
||||||
);
|
|
||||||
return await modelBadge.textContent().then((text) => text?.trim() || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the profile thinking level badge text from a card
|
|
||||||
*/
|
|
||||||
export async function getProfileThinkingLevel(
|
|
||||||
page: Page,
|
|
||||||
profileId: string
|
|
||||||
): Promise<string | null> {
|
|
||||||
const card = await getProfileCard(page, profileId);
|
|
||||||
const thinkingBadge = card.locator('span[class*="border-amber-500"]');
|
|
||||||
const isVisible = await thinkingBadge.isVisible().catch(() => false);
|
|
||||||
if (!isVisible) return null;
|
|
||||||
return await thinkingBadge.textContent().then((text) => text?.trim() || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a profile has the built-in badge
|
|
||||||
*/
|
|
||||||
export async function isBuiltInProfile(page: Page, profileId: string): Promise<boolean> {
|
|
||||||
const card = await getProfileCard(page, profileId);
|
|
||||||
const builtInBadge = card.locator('span:has-text("Built-in")');
|
|
||||||
return await builtInBadge.isVisible().catch(() => false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the edit button is visible for a profile
|
|
||||||
*/
|
|
||||||
export async function isEditButtonVisible(page: Page, profileId: string): Promise<boolean> {
|
|
||||||
const card = await getProfileCard(page, profileId);
|
|
||||||
// Hover over card to make buttons visible
|
|
||||||
await card.hover();
|
|
||||||
const editButton = await getByTestId(page, `edit-profile-${profileId}`);
|
|
||||||
// Wait for button to become visible after hover (handles CSS transition)
|
|
||||||
try {
|
|
||||||
await editButton.waitFor({ state: 'visible', timeout: 2000 });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if the delete button is visible for a profile
|
|
||||||
*/
|
|
||||||
export async function isDeleteButtonVisible(page: Page, profileId: string): Promise<boolean> {
|
|
||||||
const card = await getProfileCard(page, profileId);
|
|
||||||
// Hover over card to make buttons visible
|
|
||||||
await card.hover();
|
|
||||||
const deleteButton = await getByTestId(page, `delete-profile-${profileId}`);
|
|
||||||
// Wait for button to become visible after hover (handles CSS transition)
|
|
||||||
try {
|
|
||||||
await deleteButton.waitFor({ state: 'visible', timeout: 2000 });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Drag & Drop
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drag a profile from one position to another
|
|
||||||
* Uses the drag handle and dnd-kit library pattern
|
|
||||||
*
|
|
||||||
* Note: dnd-kit requires pointer events with specific timing for drag recognition.
|
|
||||||
* Manual mouse operations are needed because Playwright's dragTo doesn't work
|
|
||||||
* reliably with dnd-kit's pointer-based drag detection.
|
|
||||||
*
|
|
||||||
* @param fromIndex - 0-based index of the profile to drag
|
|
||||||
* @param toIndex - 0-based index of the target position
|
|
||||||
*/
|
|
||||||
export async function dragProfile(page: Page, fromIndex: number, toIndex: number): Promise<void> {
|
|
||||||
// Get all profile cards
|
|
||||||
const cards = await page.locator('[data-testid^="profile-card-"]').all();
|
|
||||||
|
|
||||||
if (fromIndex >= cards.length || toIndex >= cards.length) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid drag indices: fromIndex=${fromIndex}, toIndex=${toIndex}, total=${cards.length}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const fromCard = cards[fromIndex];
|
|
||||||
const toCard = cards[toIndex];
|
|
||||||
|
|
||||||
// Get the drag handle within the source card
|
|
||||||
const dragHandle = fromCard.locator('[data-testid^="profile-drag-handle-"]');
|
|
||||||
|
|
||||||
// Ensure drag handle is visible and ready
|
|
||||||
await dragHandle.waitFor({ state: 'visible', timeout: 5000 });
|
|
||||||
|
|
||||||
// Get bounding boxes
|
|
||||||
const handleBox = await dragHandle.boundingBox();
|
|
||||||
const toBox = await toCard.boundingBox();
|
|
||||||
|
|
||||||
if (!handleBox || !toBox) {
|
|
||||||
throw new Error('Unable to get bounding boxes for drag operation');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start position (center of drag handle)
|
|
||||||
const startX = handleBox.x + handleBox.width / 2;
|
|
||||||
const startY = handleBox.y + handleBox.height / 2;
|
|
||||||
|
|
||||||
// End position (center of target card)
|
|
||||||
const endX = toBox.x + toBox.width / 2;
|
|
||||||
const endY = toBox.y + toBox.height / 2;
|
|
||||||
|
|
||||||
// Perform manual drag operation
|
|
||||||
// dnd-kit needs pointer events in a specific sequence
|
|
||||||
await page.mouse.move(startX, startY);
|
|
||||||
await page.mouse.down();
|
|
||||||
|
|
||||||
// dnd-kit requires a brief hold before recognizing the drag gesture
|
|
||||||
// This is a library requirement, not an arbitrary timeout
|
|
||||||
await page.waitForTimeout(150);
|
|
||||||
|
|
||||||
// Move to target in steps for smoother drag recognition
|
|
||||||
await page.mouse.move(endX, endY, { steps: 10 });
|
|
||||||
|
|
||||||
// Brief pause before drop
|
|
||||||
await page.waitForTimeout(100);
|
|
||||||
|
|
||||||
await page.mouse.up();
|
|
||||||
|
|
||||||
// Wait for reorder animation to complete
|
|
||||||
await page.waitForTimeout(200);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the current order of all profile IDs
|
|
||||||
* Returns array of profile IDs in display order
|
|
||||||
*/
|
|
||||||
export async function getProfileOrder(page: Page): Promise<string[]> {
|
|
||||||
const cards = await page.locator('[data-testid^="profile-card-"]').all();
|
|
||||||
const ids: string[] = [];
|
|
||||||
|
|
||||||
for (const card of cards) {
|
|
||||||
const testId = await card.getAttribute('data-testid');
|
|
||||||
if (testId) {
|
|
||||||
// Extract profile ID from data-testid="profile-card-{id}"
|
|
||||||
const profileId = testId.replace('profile-card-', '');
|
|
||||||
ids.push(profileId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ids;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Header Actions
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Click the "Refresh Defaults" button
|
|
||||||
*/
|
|
||||||
export async function clickRefreshDefaults(page: Page): Promise<void> {
|
|
||||||
await clickElement(page, 'refresh-profiles-button');
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "test-project-1772086066067",
|
|
||||||
"version": "1.0.0"
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# Test Feature Project
|
|
||||||
|
|
||||||
This is a test project for demonstrating the Automaker system's feature implementation capabilities.
|
|
||||||
|
|
||||||
## Feature Implementation
|
|
||||||
|
|
||||||
The test feature has been successfully implemented to demonstrate:
|
|
||||||
|
|
||||||
1. Code creation and modification
|
|
||||||
2. File system operations
|
|
||||||
3. Agent workflow verification
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
✅ Test feature implementation completed
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
/**
|
|
||||||
* Test Feature Implementation
|
|
||||||
*
|
|
||||||
* This file demonstrates a simple test feature implementation
|
|
||||||
* for validating the Automaker system workflow.
|
|
||||||
*/
|
|
||||||
|
|
||||||
class TestFeature {
|
|
||||||
constructor(name = 'Test Feature') {
|
|
||||||
this.name = name;
|
|
||||||
this.status = 'running';
|
|
||||||
this.createdAt = new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the test feature
|
|
||||||
* @returns {Object} Execution result
|
|
||||||
*/
|
|
||||||
execute() {
|
|
||||||
console.log(`Executing ${this.name}...`);
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
success: true,
|
|
||||||
message: 'Test feature executed successfully',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
feature: this.name,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.status = 'completed';
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get feature status
|
|
||||||
* @returns {string} Current status
|
|
||||||
*/
|
|
||||||
getStatus() {
|
|
||||||
return this.status;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get feature info
|
|
||||||
* @returns {Object} Feature information
|
|
||||||
*/
|
|
||||||
getInfo() {
|
|
||||||
return {
|
|
||||||
name: this.name,
|
|
||||||
status: this.status,
|
|
||||||
createdAt: this.createdAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export for use in tests
|
|
||||||
module.exports = TestFeature;
|
|
||||||
|
|
||||||
// Example usage
|
|
||||||
if (require.main === module) {
|
|
||||||
const feature = new TestFeature();
|
|
||||||
const result = feature.execute();
|
|
||||||
console.log(JSON.stringify(result, null, 2));
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
/**
|
|
||||||
* Test Feature Unit Tests
|
|
||||||
*
|
|
||||||
* Simple tests to verify the test feature implementation
|
|
||||||
*/
|
|
||||||
|
|
||||||
const TestFeature = require('./test-feature');
|
|
||||||
|
|
||||||
function runTests() {
|
|
||||||
let passed = 0;
|
|
||||||
let failed = 0;
|
|
||||||
|
|
||||||
console.log('Running Test Feature Tests...\n');
|
|
||||||
|
|
||||||
// Test 1: Feature creation
|
|
||||||
try {
|
|
||||||
const feature = new TestFeature('Test Feature');
|
|
||||||
if (feature.name === 'Test Feature' && feature.status === 'running') {
|
|
||||||
console.log('✓ Test 1: Feature creation - PASSED');
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
console.log('✗ Test 1: Feature creation - FAILED');
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('✗ Test 1: Feature creation - FAILED:', error.message);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 2: Feature execution
|
|
||||||
try {
|
|
||||||
const feature = new TestFeature();
|
|
||||||
const result = feature.execute();
|
|
||||||
if (result.success === true && feature.status === 'completed') {
|
|
||||||
console.log('✓ Test 2: Feature execution - PASSED');
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
console.log('✗ Test 2: Feature execution - FAILED');
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('✗ Test 2: Feature execution - FAILED:', error.message);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 3: Get status
|
|
||||||
try {
|
|
||||||
const feature = new TestFeature();
|
|
||||||
const status = feature.getStatus();
|
|
||||||
if (status === 'running') {
|
|
||||||
console.log('✓ Test 3: Get status - PASSED');
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
console.log('✗ Test 3: Get status - FAILED');
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('✗ Test 3: Get status - FAILED:', error.message);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test 4: Get info
|
|
||||||
try {
|
|
||||||
const feature = new TestFeature('My Test Feature');
|
|
||||||
const info = feature.getInfo();
|
|
||||||
if (info.name === 'My Test Feature' && info.status === 'running' && info.createdAt) {
|
|
||||||
console.log('✓ Test 4: Get info - PASSED');
|
|
||||||
passed++;
|
|
||||||
} else {
|
|
||||||
console.log('✗ Test 4: Get info - FAILED');
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('✗ Test 4: Get info - FAILED:', error.message);
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`\nTest Results: ${passed} passed, ${failed} failed`);
|
|
||||||
return failed === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run tests
|
|
||||||
if (require.main === module) {
|
|
||||||
const success = runTests();
|
|
||||||
process.exit(success ? 0 : 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { runTests };
|
|
||||||
Reference in New Issue
Block a user