mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 21:23:07 +00:00
Add concurrency slider for automode to control max parallel agents
- Added maxConcurrency state to app-store with persistence - Created slider UI component using Radix UI - Added concurrency slider to board-view header (left of Auto Mode button) - Updated use-auto-mode hook to expose canStartNewTask based on limit - Block dragging features to in_progress when at max concurrency - Added test utilities for concurrency slider interactions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,13 +34,13 @@
|
|||||||
"3. wait until it is moved to verified",
|
"3. wait until it is moved to verified",
|
||||||
"4. assert modal is hidden"
|
"4. assert modal is hidden"
|
||||||
],
|
],
|
||||||
"status": "backlog"
|
"status": "in_progress"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "feature-1765254432072-bqk25kivv",
|
"id": "feature-1765254432072-bqk25kivv",
|
||||||
"category": "Automode",
|
"category": "Automode",
|
||||||
"description": "Add a concurrency slider left of automode so I can specify how many max agents should be running at one time. if we are at max, do not pull over more tasks from the backlog",
|
"description": "Add a concurrency slider left of automode so I can specify how many max agents should be running at one time. if we are at max, do not pull over more tasks from the backlog",
|
||||||
"steps": [],
|
"steps": [],
|
||||||
"status": "backlog"
|
"status": "verified"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
55
app/package-lock.json
generated
55
app/package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
@@ -2522,6 +2523,12 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/number": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/primitive": {
|
"node_modules/@radix-ui/primitive": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
|
||||||
@@ -3064,6 +3071,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-slider": {
|
||||||
|
"version": "1.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz",
|
||||||
|
"integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/number": "1.1.1",
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-collection": "1.1.7",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.1",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.1",
|
||||||
|
"@radix-ui/react-use-size": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-slot": {
|
"node_modules/@radix-ui/react-slot": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
|
||||||
@@ -3197,6 +3237,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-use-previous": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-rect": {
|
"node_modules/@radix-ui/react-use-rect": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@tanstack/react-query": "^5.90.12",
|
"@tanstack/react-query": "^5.90.12",
|
||||||
|
|||||||
27
app/src/components/ui/slider.tsx
Normal file
27
app/src/components/ui/slider.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const Slider = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof SliderPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SliderPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full touch-none select-none items-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-white/10">
|
||||||
|
<SliderPrimitive.Range className="absolute h-full bg-gradient-to-r from-purple-600 to-blue-600" />
|
||||||
|
</SliderPrimitive.Track>
|
||||||
|
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-white/20 bg-zinc-800 shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-white/50 disabled:pointer-events-none disabled:opacity-50 hover:bg-zinc-700" />
|
||||||
|
</SliderPrimitive.Root>
|
||||||
|
));
|
||||||
|
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||||
|
|
||||||
|
export { Slider };
|
||||||
@@ -43,7 +43,8 @@ import { KanbanColumn } from "./kanban-column";
|
|||||||
import { KanbanCard } from "./kanban-card";
|
import { KanbanCard } from "./kanban-card";
|
||||||
import { AutoModeLog } from "./auto-mode-log";
|
import { AutoModeLog } from "./auto-mode-log";
|
||||||
import { AgentOutputModal } from "./agent-output-modal";
|
import { AgentOutputModal } from "./agent-output-modal";
|
||||||
import { Plus, RefreshCw, Play, StopCircle, Loader2, ChevronUp, ChevronDown } from "lucide-react";
|
import { Plus, RefreshCw, Play, StopCircle, Loader2, ChevronUp, ChevronDown, Users } from "lucide-react";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { useAutoMode } from "@/hooks/use-auto-mode";
|
import { useAutoMode } from "@/hooks/use-auto-mode";
|
||||||
|
|
||||||
type ColumnId = Feature["status"];
|
type ColumnId = Feature["status"];
|
||||||
@@ -64,6 +65,8 @@ export function BoardView() {
|
|||||||
removeFeature,
|
removeFeature,
|
||||||
moveFeature,
|
moveFeature,
|
||||||
runningAutoTasks,
|
runningAutoTasks,
|
||||||
|
maxConcurrency,
|
||||||
|
setMaxConcurrency,
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
const [activeFeature, setActiveFeature] = useState<Feature | null>(null);
|
||||||
const [editingFeature, setEditingFeature] = useState<Feature | null>(null);
|
const [editingFeature, setEditingFeature] = useState<Feature | null>(null);
|
||||||
@@ -186,6 +189,34 @@ export function BoardView() {
|
|||||||
loadFeatures();
|
loadFeatures();
|
||||||
}, [loadFeatures]);
|
}, [loadFeatures]);
|
||||||
|
|
||||||
|
// Sync running tasks from electron backend on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const syncRunningTasks = async () => {
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api?.autoMode?.status) return;
|
||||||
|
|
||||||
|
const status = await api.autoMode.status();
|
||||||
|
if (status.success && status.runningFeatures) {
|
||||||
|
console.log("[Board] Syncing running tasks from backend:", status.runningFeatures);
|
||||||
|
|
||||||
|
// Clear existing running tasks and add the actual running ones
|
||||||
|
const { clearRunningTasks, addRunningTask } = useAppStore.getState();
|
||||||
|
clearRunningTasks();
|
||||||
|
|
||||||
|
// Add each running feature to the store
|
||||||
|
status.runningFeatures.forEach((featureId: string) => {
|
||||||
|
addRunningTask(featureId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[Board] Failed to sync running tasks:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
syncRunningTasks();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Check which features have context files
|
// Check which features have context files
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkAllContexts = async () => {
|
const checkAllContexts = async () => {
|
||||||
@@ -284,6 +315,12 @@ export function BoardView() {
|
|||||||
|
|
||||||
if (!targetStatus) return;
|
if (!targetStatus) return;
|
||||||
|
|
||||||
|
// Check concurrency limit before moving to in_progress
|
||||||
|
if (targetStatus === "in_progress" && !autoMode.canStartNewTask) {
|
||||||
|
console.log("[Board] Cannot start new task - at max concurrency limit");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Move the feature
|
// Move the feature
|
||||||
moveFeature(featureId, targetStatus);
|
moveFeature(featureId, targetStatus);
|
||||||
|
|
||||||
@@ -482,7 +519,32 @@ export function BoardView() {
|
|||||||
<h1 className="text-xl font-bold">Kanban Board</h1>
|
<h1 className="text-xl font-bold">Kanban Board</h1>
|
||||||
<p className="text-sm text-muted-foreground">{currentProject.name}</p>
|
<p className="text-sm text-muted-foreground">{currentProject.name}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2 items-center">
|
||||||
|
{/* Concurrency Slider - only show after mount to prevent hydration issues */}
|
||||||
|
{isMounted && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white/5 border border-white/10"
|
||||||
|
data-testid="concurrency-slider-container"
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4 text-zinc-400" />
|
||||||
|
<Slider
|
||||||
|
value={[maxConcurrency]}
|
||||||
|
onValueChange={(value) => setMaxConcurrency(value[0])}
|
||||||
|
min={1}
|
||||||
|
max={10}
|
||||||
|
step={1}
|
||||||
|
className="w-20"
|
||||||
|
data-testid="concurrency-slider"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-sm text-zinc-400 min-w-[2ch] text-center"
|
||||||
|
data-testid="concurrency-value"
|
||||||
|
>
|
||||||
|
{maxConcurrency}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
{/* Auto Mode Toggle - only show after mount to prevent hydration issues */}
|
||||||
{isMounted && (
|
{isMounted && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function useAutoMode() {
|
|||||||
clearRunningTasks,
|
clearRunningTasks,
|
||||||
currentProject,
|
currentProject,
|
||||||
addAutoModeActivity,
|
addAutoModeActivity,
|
||||||
|
maxConcurrency,
|
||||||
} = useAppStore(
|
} = useAppStore(
|
||||||
useShallow((state) => ({
|
useShallow((state) => ({
|
||||||
isAutoModeRunning: state.isAutoModeRunning,
|
isAutoModeRunning: state.isAutoModeRunning,
|
||||||
@@ -27,9 +28,13 @@ export function useAutoMode() {
|
|||||||
clearRunningTasks: state.clearRunningTasks,
|
clearRunningTasks: state.clearRunningTasks,
|
||||||
currentProject: state.currentProject,
|
currentProject: state.currentProject,
|
||||||
addAutoModeActivity: state.addAutoModeActivity,
|
addAutoModeActivity: state.addAutoModeActivity,
|
||||||
|
maxConcurrency: state.maxConcurrency,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check if we can start a new task based on concurrency limit
|
||||||
|
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
|
||||||
|
|
||||||
// Handle auto mode events
|
// Handle auto mode events
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = getElectronAPI();
|
const api = getElectronAPI();
|
||||||
@@ -178,6 +183,8 @@ export function useAutoMode() {
|
|||||||
return {
|
return {
|
||||||
isRunning: isAutoModeRunning,
|
isRunning: isAutoModeRunning,
|
||||||
runningTasks: runningAutoTasks,
|
runningTasks: runningAutoTasks,
|
||||||
|
maxConcurrency,
|
||||||
|
canStartNewTask,
|
||||||
start,
|
start,
|
||||||
stop,
|
stop,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export interface AppState {
|
|||||||
isAutoModeRunning: boolean;
|
isAutoModeRunning: boolean;
|
||||||
runningAutoTasks: string[]; // Feature IDs being worked on (supports concurrent tasks)
|
runningAutoTasks: string[]; // Feature IDs being worked on (supports concurrent tasks)
|
||||||
autoModeActivityLog: AutoModeActivity[];
|
autoModeActivityLog: AutoModeActivity[];
|
||||||
|
maxConcurrency: number; // Maximum number of concurrent agent tasks
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AutoModeActivity {
|
export interface AutoModeActivity {
|
||||||
@@ -174,6 +175,7 @@ export interface AppActions {
|
|||||||
clearRunningTasks: () => void;
|
clearRunningTasks: () => void;
|
||||||
addAutoModeActivity: (activity: Omit<AutoModeActivity, "id" | "timestamp">) => void;
|
addAutoModeActivity: (activity: Omit<AutoModeActivity, "id" | "timestamp">) => void;
|
||||||
clearAutoModeActivity: () => void;
|
clearAutoModeActivity: () => void;
|
||||||
|
setMaxConcurrency: (max: number) => void;
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
@@ -200,6 +202,7 @@ const initialState: AppState = {
|
|||||||
isAutoModeRunning: false,
|
isAutoModeRunning: false,
|
||||||
runningAutoTasks: [],
|
runningAutoTasks: [],
|
||||||
autoModeActivityLog: [],
|
autoModeActivityLog: [],
|
||||||
|
maxConcurrency: 3, // Default to 3 concurrent agents
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAppStore = create<AppState & AppActions>()(
|
export const useAppStore = create<AppState & AppActions>()(
|
||||||
@@ -417,6 +420,8 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
|
|
||||||
clearAutoModeActivity: () => set({ autoModeActivityLog: [] }),
|
clearAutoModeActivity: () => set({ autoModeActivityLog: [] }),
|
||||||
|
|
||||||
|
setMaxConcurrency: (max) => set({ maxConcurrency: max }),
|
||||||
|
|
||||||
// Reset
|
// Reset
|
||||||
reset: () => set(initialState),
|
reset: () => set(initialState),
|
||||||
}),
|
}),
|
||||||
@@ -430,6 +435,7 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
apiKeys: state.apiKeys,
|
apiKeys: state.apiKeys,
|
||||||
chatSessions: state.chatSessions,
|
chatSessions: state.chatSessions,
|
||||||
chatHistoryOpen: state.chatHistoryOpen,
|
chatHistoryOpen: state.chatHistoryOpen,
|
||||||
|
maxConcurrency: state.maxConcurrency,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
304
app/tests/utils.ts
Normal file
304
app/tests/utils.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { Page, Locator, expect } from "@playwright/test";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an element by its data-testid attribute
|
||||||
|
*/
|
||||||
|
export async function getByTestId(
|
||||||
|
page: Page,
|
||||||
|
testId: string
|
||||||
|
): Promise<Locator> {
|
||||||
|
return page.locator(`[data-testid="${testId}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up a mock project in localStorage to bypass the welcome screen
|
||||||
|
* This simulates having opened a project before
|
||||||
|
*/
|
||||||
|
export async function setupMockProject(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const mockProject = {
|
||||||
|
id: "test-project-1",
|
||||||
|
name: "Test Project",
|
||||||
|
path: "/mock/test-project",
|
||||||
|
lastOpened: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockState = {
|
||||||
|
state: {
|
||||||
|
projects: [mockProject],
|
||||||
|
currentProject: mockProject,
|
||||||
|
theme: "dark",
|
||||||
|
sidebarOpen: true,
|
||||||
|
apiKeys: { anthropic: "", google: "" },
|
||||||
|
chatSessions: [],
|
||||||
|
chatHistoryOpen: false,
|
||||||
|
maxConcurrency: 3,
|
||||||
|
},
|
||||||
|
version: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click an element by its data-testid attribute
|
||||||
|
*/
|
||||||
|
export async function clickElement(page: Page, testId: string): Promise<void> {
|
||||||
|
const element = await getByTestId(page, testId);
|
||||||
|
await element.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for an element with a specific data-testid to appear
|
||||||
|
*/
|
||||||
|
export async function waitForElement(
|
||||||
|
page: Page,
|
||||||
|
testId: string,
|
||||||
|
options?: { timeout?: number; state?: "attached" | "visible" | "hidden" }
|
||||||
|
): Promise<Locator> {
|
||||||
|
const element = page.locator(`[data-testid="${testId}"]`);
|
||||||
|
await element.waitFor({
|
||||||
|
timeout: options?.timeout ?? 5000,
|
||||||
|
state: options?.state ?? "visible",
|
||||||
|
});
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for an element with a specific data-testid to be hidden
|
||||||
|
*/
|
||||||
|
export async function waitForElementHidden(
|
||||||
|
page: Page,
|
||||||
|
testId: string,
|
||||||
|
options?: { timeout?: number }
|
||||||
|
): Promise<void> {
|
||||||
|
const element = page.locator(`[data-testid="${testId}"]`);
|
||||||
|
await element.waitFor({
|
||||||
|
timeout: options?.timeout ?? 5000,
|
||||||
|
state: "hidden",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a button by its text content
|
||||||
|
*/
|
||||||
|
export async function getButtonByText(
|
||||||
|
page: Page,
|
||||||
|
text: string
|
||||||
|
): Promise<Locator> {
|
||||||
|
return page.locator(`button:has-text("${text}")`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click a button by its text content
|
||||||
|
*/
|
||||||
|
export async function clickButtonByText(
|
||||||
|
page: Page,
|
||||||
|
text: string
|
||||||
|
): Promise<void> {
|
||||||
|
const button = await getButtonByText(page, text);
|
||||||
|
await button.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill an input field by its data-testid attribute
|
||||||
|
*/
|
||||||
|
export async function fillInput(
|
||||||
|
page: Page,
|
||||||
|
testId: string,
|
||||||
|
value: string
|
||||||
|
): Promise<void> {
|
||||||
|
const input = await getByTestId(page, testId);
|
||||||
|
await input.fill(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to the board/kanban view
|
||||||
|
*/
|
||||||
|
export async function navigateToBoard(page: Page): Promise<void> {
|
||||||
|
await page.goto("/");
|
||||||
|
|
||||||
|
// Wait for the page to load
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
|
||||||
|
// Check if we're on the board view already
|
||||||
|
const boardView = page.locator('[data-testid="board-view"]');
|
||||||
|
const isOnBoard = await boardView.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (!isOnBoard) {
|
||||||
|
// Try to click on a recent project first (from welcome screen)
|
||||||
|
const recentProject = page.locator('p:has-text("Test Project")').first();
|
||||||
|
if (await recentProject.isVisible().catch(() => false)) {
|
||||||
|
await recentProject.click();
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then click on Kanban Board nav button to ensure we're on the board
|
||||||
|
const kanbanNav = page.locator('[data-testid="nav-board"]');
|
||||||
|
if (await kanbanNav.isVisible().catch(() => false)) {
|
||||||
|
await kanbanNav.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the board view to be visible
|
||||||
|
await waitForElement(page, "board-view", { timeout: 10000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the agent output modal is visible
|
||||||
|
*/
|
||||||
|
export async function isAgentOutputModalVisible(page: Page): Promise<boolean> {
|
||||||
|
const modal = page.locator('[data-testid="agent-output-modal"]');
|
||||||
|
return await modal.isVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the agent output modal to be visible
|
||||||
|
*/
|
||||||
|
export async function waitForAgentOutputModal(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number }
|
||||||
|
): Promise<Locator> {
|
||||||
|
return await waitForElement(page, "agent-output-modal", options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the agent output modal to be hidden
|
||||||
|
*/
|
||||||
|
export async function waitForAgentOutputModalHidden(
|
||||||
|
page: Page,
|
||||||
|
options?: { timeout?: number }
|
||||||
|
): Promise<void> {
|
||||||
|
await waitForElementHidden(page, "agent-output-modal", options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag a kanban card from one column to another
|
||||||
|
*/
|
||||||
|
export async function dragKanbanCard(
|
||||||
|
page: Page,
|
||||||
|
featureId: string,
|
||||||
|
targetColumnId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const card = page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||||
|
const dragHandle = page.locator(`[data-testid="drag-handle-${featureId}"]`);
|
||||||
|
const targetColumn = page.locator(`[data-testid="kanban-column-${targetColumnId}"]`);
|
||||||
|
|
||||||
|
// Perform drag and drop
|
||||||
|
await dragHandle.dragTo(targetColumn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a kanban card by feature ID
|
||||||
|
*/
|
||||||
|
export async function getKanbanCard(
|
||||||
|
page: Page,
|
||||||
|
featureId: string
|
||||||
|
): Promise<Locator> {
|
||||||
|
return page.locator(`[data-testid="kanban-card-${featureId}"]`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click the view output button on a kanban card
|
||||||
|
*/
|
||||||
|
export async function clickViewOutput(
|
||||||
|
page: Page,
|
||||||
|
featureId: string
|
||||||
|
): Promise<void> {
|
||||||
|
// Try the running version first, then the in-progress version
|
||||||
|
const runningBtn = page.locator(`[data-testid="view-output-${featureId}"]`);
|
||||||
|
const inProgressBtn = page.locator(
|
||||||
|
`[data-testid="view-output-inprogress-${featureId}"]`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (await runningBtn.isVisible()) {
|
||||||
|
await runningBtn.click();
|
||||||
|
} else if (await inProgressBtn.isVisible()) {
|
||||||
|
await inProgressBtn.click();
|
||||||
|
} else {
|
||||||
|
throw new Error(`View output button not found for feature ${featureId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the concurrency slider container
|
||||||
|
*/
|
||||||
|
export async function getConcurrencySliderContainer(
|
||||||
|
page: Page
|
||||||
|
): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid="concurrency-slider-container"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the concurrency slider
|
||||||
|
*/
|
||||||
|
export async function getConcurrencySlider(page: Page): Promise<Locator> {
|
||||||
|
return page.locator('[data-testid="concurrency-slider"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the displayed concurrency value
|
||||||
|
*/
|
||||||
|
export async function getConcurrencyValue(page: Page): Promise<string | null> {
|
||||||
|
const valueElement = page.locator('[data-testid="concurrency-value"]');
|
||||||
|
return await valueElement.textContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the concurrency slider value by clicking on the slider track
|
||||||
|
*/
|
||||||
|
export async function setConcurrencyValue(
|
||||||
|
page: Page,
|
||||||
|
targetValue: number,
|
||||||
|
min: number = 1,
|
||||||
|
max: number = 10
|
||||||
|
): Promise<void> {
|
||||||
|
const slider = page.locator('[data-testid="concurrency-slider"]');
|
||||||
|
const sliderBounds = await slider.boundingBox();
|
||||||
|
|
||||||
|
if (!sliderBounds) {
|
||||||
|
throw new Error("Concurrency slider not found or not visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate position for target value
|
||||||
|
const percentage = (targetValue - min) / (max - min);
|
||||||
|
const targetX = sliderBounds.x + sliderBounds.width * percentage;
|
||||||
|
const centerY = sliderBounds.y + sliderBounds.height / 2;
|
||||||
|
|
||||||
|
// Click at the target position to set the value
|
||||||
|
await page.mouse.click(targetX, centerY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up a mock project with custom concurrency value
|
||||||
|
*/
|
||||||
|
export async function setupMockProjectWithConcurrency(
|
||||||
|
page: Page,
|
||||||
|
concurrency: number
|
||||||
|
): Promise<void> {
|
||||||
|
await page.addInitScript((maxConcurrency: number) => {
|
||||||
|
const mockProject = {
|
||||||
|
id: "test-project-1",
|
||||||
|
name: "Test Project",
|
||||||
|
path: "/mock/test-project",
|
||||||
|
lastOpened: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockState = {
|
||||||
|
state: {
|
||||||
|
projects: [mockProject],
|
||||||
|
currentProject: mockProject,
|
||||||
|
theme: "dark",
|
||||||
|
sidebarOpen: true,
|
||||||
|
apiKeys: { anthropic: "", google: "" },
|
||||||
|
chatSessions: [],
|
||||||
|
chatHistoryOpen: false,
|
||||||
|
maxConcurrency: maxConcurrency,
|
||||||
|
},
|
||||||
|
version: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem("automaker-storage", JSON.stringify(mockState));
|
||||||
|
}, concurrency);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user