Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions src/openutm_verification/server/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from typing import Any, Optional, TypeVar

import uvicorn
from fastapi import Body, Depends, FastAPI, Request
from fastapi import Body, Depends, FastAPI, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from fastapi.staticfiles import StaticFiles
Expand Down Expand Up @@ -402,11 +402,18 @@ async def run_scenario(scenario: ScenarioDefinition, runner: SessionManager = De


@app.post("/run-scenario-async")
async def run_scenario_async(scenario: ScenarioDefinition, runner: SessionManager = Depends(get_session_manager)):
run_id = await runner.start_scenario_task(scenario)
async def run_scenario_async(scenario: ScenarioDefinition, auto_pause: bool = Query(False), runner: SessionManager = Depends(get_session_manager)):
run_id = await runner.start_scenario_task(scenario, auto_pause=auto_pause)
return {"run_id": run_id}


@app.post("/scenario/advance-step")
async def advance_step(runner: SessionManager = Depends(get_session_manager)):
"""Advance execution by one step when in step-by-step (auto-pause) mode."""
runner._step_resume_event.set()
return {"advanced": True}
Comment on lines +410 to +414


@app.post("/stop-scenario")
async def stop_scenario(runner: SessionManager = Depends(get_session_manager)):
"""Stop the currently running scenario."""
Expand All @@ -419,6 +426,7 @@ async def run_scenario_events(runner: SessionManager = Depends(get_session_manag
async def event_stream():
# Track consecutive idle iterations for adaptive sleep
idle_iterations = 0
paused_event_sent = False

while True:
status_payload = runner.get_run_status()
Expand All @@ -432,6 +440,14 @@ async def event_stream():
had_results = True
idle_iterations = 0

# Emit paused event once per pause boundary
if runner._is_paused and not paused_event_sent:
yield "event: paused\ndata: {}\n\n"
paused_event_sent = True
idle_iterations = 0
elif not runner._is_paused:
paused_event_sent = False

if status_payload.get("status") != "running" and not runner.has_pending_tasks():
done_payload = {
"status": status_payload.get("status"),
Expand Down
26 changes: 25 additions & 1 deletion src/openutm_verification/server/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,22 @@ def __init__(self, config_path: str = "config/default.yaml"):
self.current_output_dir: Path | None = None
self.current_timestamp_str: str | None = None
self.current_start_time: datetime | None = None
self._auto_pause: bool = False
self._is_paused: bool = False
self._step_resume_event: asyncio.Event = asyncio.Event()
self._initialized = True

async def start_scenario_task(self, scenario: ScenarioDefinition):
async def start_scenario_task(self, scenario: ScenarioDefinition, auto_pause: bool = False):
if not self.session_resolver:
await self.initialize_session()

if self.current_run_task and not self.current_run_task.done():
self.current_run_task.cancel()

self._auto_pause = auto_pause
self._is_paused = False
self._step_resume_event = asyncio.Event() # starts CLEARED → first step pauses immediately

# Each server-driven scenario must produce its own isolated run
# directory so reports (including ``allure-results``) are not shared
# across invocations. ``run_scenario`` only allocates a new timestamp
Expand Down Expand Up @@ -107,6 +114,10 @@ async def stop_scenario(self) -> bool:
stopped = False
tasks_to_cancel = []

# Unblock a paused step-by-step run so the task can be cancelled
self._is_paused = False
self._step_resume_event.set()

# Cancel the main scenario task
if self.current_run_task and not self.current_run_task.done():
self.current_run_task.cancel()
Expand Down Expand Up @@ -810,6 +821,19 @@ async def run_scenario(self, scenario: ScenarioDefinition) -> List[StepResult]:
self.session_context.update_result(skipped_result)
continue

# Pause before executing this step if step-by-step mode is active.
# For normal runs, explicitly clear any stale step-by-step state so a
# previous paused execution cannot leak into this run and block it.
if self._auto_pause:
self._is_paused = True
await self._step_resume_event.wait()
self._step_resume_event.clear()
self._is_paused = False
else:
self._auto_pause = False
self._is_paused = False
self._step_resume_event.set()

# Wait for declared dependencies (useful for background steps)
await self._wait_for_dependencies(step)

Expand Down
5 changes: 5 additions & 0 deletions tests/test_continue_on_error_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ def _make_runner() -> SessionManager:
runner.current_run_error = None
runner.data_files = None
runner._stop_event = None
runner._auto_pause = False
runner._is_paused = False
import asyncio

runner._step_resume_event = asyncio.Event()
return runner


Expand Down
61 changes: 60 additions & 1 deletion web-editor/src/components/ScenarioEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,8 @@ const ScenarioEditorContent = () => {
edgesRef.current = edges;
}, [nodes, edges]);

const { isRunning, runScenario, stopScenario } = useScenarioRunner();
const { isRunning, isPaused, runScenario, stopScenario, advanceStep } = useScenarioRunner();
const [runMode, setRunMode] = useState<'all' | 'step' | null>(null);
const { handleSaveToServer, handleSaveAs } = useScenarioFile(
nodes,
edges,
Expand Down Expand Up @@ -862,6 +863,7 @@ const ScenarioEditorContent = () => {
}, []);

const handleRun = useCallback(async () => {
setRunMode('all');
// Clear previous results/errors from the UI immediately
setNodes((nds) => nds.map(node => ({
...node,
Expand Down Expand Up @@ -907,6 +909,58 @@ const ScenarioEditorContent = () => {
currentScenarioGroups,
currentScenarioDescription
);
setRunMode(null);
}, [
runScenario,
currentScenarioName,
currentScenarioGroups,
currentScenarioDescription,
operations,
setNodes,
updateNodesWithResults,
reactFlowInstance
]);

const handleStepByStep = useCallback(async () => {
setRunMode('step');
// Clear previous results/errors from the UI immediately
setNodes((nds) => nds.map(node => ({
...node,
data: {
...node.data,
status: undefined,
result: undefined
}
})));

const currentNodes = reactFlowInstance ? reactFlowInstance.getNodes() : nodesRef.current;
const currentEdges = reactFlowInstance ? reactFlowInstance.getEdges() : edgesRef.current;

const onStepComplete = (stepResult: { id: string; status: 'success' | 'failure' | 'error' | 'skipped' | 'running' | 'waiting'; result?: unknown }) => {
setNodes((nds) => updateNodesWithResults(nds, [stepResult]));
};

const onStepStart = (nodeId: string) => {
setNodes((nds) => nds.map(node => {
if (node.id === nodeId) {
return { ...node, data: { ...node.data, status: 'waiting' } };
}
return node;
}));
};

await runScenario(
currentNodes,
currentEdges,
currentScenarioName || "Interactive Session",
onStepComplete,
onStepStart,
operations,
currentScenarioGroups,
currentScenarioDescription,
true
);
setRunMode(null);
}, [
runScenario,
currentScenarioName,
Expand All @@ -919,6 +973,7 @@ const ScenarioEditorContent = () => {
]);

const handleStop = useCallback(async () => {
setRunMode(null);
await stopScenario();
// Clear running/waiting status from all nodes
setNodes((nds) => nds.map(node => {
Expand Down Expand Up @@ -1185,7 +1240,11 @@ const ScenarioEditorContent = () => {
onSaveAs={handleSaveAs}
onRun={handleRun}
onStop={handleStop}
onRunStepByStep={handleStepByStep}
onAdvanceStep={advanceStep}
isRunning={isRunning}
isPaused={isPaused}
isStepMode={runMode === 'step'}
groupedByPhase={groupedByPhase}
onToggleGroupByPhase={toggleGroupByPhase}
/>
Expand Down
34 changes: 28 additions & 6 deletions web-editor/src/components/ScenarioEditor/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Activity, Moon, Sun, Layout, FilePlus, Save, Play, Loader2, Copy, Square, Layers, Settings } from 'lucide-react';
import { Activity, Moon, Sun, Layout, FilePlus, Save, Play, Loader2, Copy, Square, Layers, Settings, SkipForward, StepForward } from 'lucide-react';
import styles from '../../styles/Header.module.css';
import btnStyles from '../../styles/Button.module.css';

Expand All @@ -11,7 +11,11 @@ interface HeaderProps {
onSaveAs: () => void;
onRun: () => void;
onStop: () => void;
onRunStepByStep: () => void;
onAdvanceStep: () => void;
isRunning: boolean;
isPaused: boolean;
isStepMode: boolean;
groupedByPhase: boolean;
onToggleGroupByPhase: () => void;
}
Expand All @@ -25,7 +29,11 @@ export const Header = ({
onSaveAs,
onRun,
onStop,
onRunStepByStep,
onAdvanceStep,
isRunning,
isPaused,
isStepMode,
groupedByPhase,
onToggleGroupByPhase,
}: HeaderProps) => {
Expand Down Expand Up @@ -64,11 +72,25 @@ export const Header = ({
<Copy size={16} />
Save As
</button>
<button className={`${btnStyles.button} ${btnStyles.primary}`} onClick={onRun} disabled={isRunning} title="Run scenario" type="button">
{isRunning ? <Loader2 size={16} className={styles.spin} /> : <Play size={16} />}
Run Scenario
</button>
{isRunning && (
{(!isRunning || !isStepMode) && (
<button className={`${btnStyles.button} ${btnStyles.primary}`} onClick={onRun} disabled={isRunning} title="Run all steps without pause" type="button">
{isRunning && !isStepMode ? <Loader2 size={16} className={styles.spin} /> : <Play size={16} />}
Run All
</button>
)}
{(!isRunning || isStepMode) && (
<button className={`${btnStyles.button} ${btnStyles.primary}`} onClick={onRunStepByStep} disabled={isRunning} title="Run scenario step by step" type="button">
{isRunning && isStepMode ? <Loader2 size={16} className={styles.spin} /> : <SkipForward size={16} />}
Step by Step
</button>
)}
{isRunning && isStepMode && isPaused && (
<button className={`${btnStyles.button} ${btnStyles.primary}`} onClick={onAdvanceStep} title="Execute next step" type="button">
<StepForward size={16} />
Next Step
</button>
)}
{isRunning && isStepMode && (
<button className={`${btnStyles.button} ${btnStyles.danger}`} onClick={onStop} title="Stop scenario" type="button">
<Square size={16} />
Stop
Expand Down
55 changes: 50 additions & 5 deletions web-editor/src/components/ScenarioEditor/__tests__/Header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,11 @@ describe('Header', () => {
onSaveAs: vi.fn(),
onRun: vi.fn(),
onStop: vi.fn(),
onRunStepByStep: vi.fn(),
onAdvanceStep: vi.fn(),
isRunning: false,
isPaused: false,
isStepMode: false,
groupedByPhase: false,
onToggleGroupByPhase: vi.fn(),
};
Expand Down Expand Up @@ -57,16 +61,57 @@ describe('Header', () => {
expect(defaultProps.onSaveAs).toHaveBeenCalled();
});

it('calls onRun when Run button is clicked', () => {
it('calls onRun when Run All button is clicked', () => {
render(<Header {...defaultProps} />);
const runButton = screen.getByText('Run Scenario');
const runButton = screen.getByText('Run All');
fireEvent.click(runButton);
expect(defaultProps.onRun).toHaveBeenCalled();
});

it('shows loading state when isRunning is true', () => {
render(<Header {...defaultProps} isRunning={true} />);
const runButton = screen.getByText('Run Scenario');
it('calls onRunStepByStep when Step by Step button is clicked', () => {
render(<Header {...defaultProps} />);
const stepButton = screen.getByText('Step by Step');
fireEvent.click(stepButton);
expect(defaultProps.onRunStepByStep).toHaveBeenCalled();
});

it('shows loading state and hides Step by Step when running in run-all mode', () => {
render(<Header {...defaultProps} isRunning={true} isStepMode={false} />);
const runButton = screen.getByText('Run All');
expect(runButton).toBeDisabled();
expect(screen.queryByText('Step by Step')).not.toBeInTheDocument();
});

it('shows loading state and hides Run All when running in step mode', () => {
render(<Header {...defaultProps} isRunning={true} isStepMode={true} />);
const stepButton = screen.getByText('Step by Step');
expect(stepButton).toBeDisabled();
expect(screen.queryByText('Run All')).not.toBeInTheDocument();
});

it('shows Next Step button only when running in step mode and paused', () => {
render(<Header {...defaultProps} isRunning={true} isStepMode={true} isPaused={true} />);
expect(screen.getByText('Next Step')).toBeInTheDocument();
});

it('does not show Next Step button when not paused', () => {
render(<Header {...defaultProps} isRunning={true} isStepMode={true} isPaused={false} />);
expect(screen.queryByText('Next Step')).not.toBeInTheDocument();
});

it('shows Stop button only in step mode', () => {
render(<Header {...defaultProps} isRunning={true} isStepMode={true} />);
expect(screen.getByText('Stop')).toBeInTheDocument();
});

it('does not show Stop button in run-all mode', () => {
render(<Header {...defaultProps} isRunning={true} isStepMode={false} />);
expect(screen.queryByText('Stop')).not.toBeInTheDocument();
});

it('calls onAdvanceStep when Next Step button is clicked', () => {
render(<Header {...defaultProps} isRunning={true} isStepMode={true} isPaused={true} />);
fireEvent.click(screen.getByText('Next Step'));
expect(defaultProps.onAdvanceStep).toHaveBeenCalled();
});
});
15 changes: 14 additions & 1 deletion web-editor/src/hooks/__tests__/useScenarioRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ describe('useScenarioRunner', () => {
vi.restoreAllMocks();
});

it('initializes with isRunning false', () => {
it('initializes with isRunning and isPaused false', () => {
const mockEventSource = {
onmessage: null as ((event: MessageEvent) => void) | null,
onerror: null as ((event: Event) => void) | null,
Expand All @@ -42,6 +42,19 @@ describe('useScenarioRunner', () => {

const { result } = renderHook(() => useScenarioRunner());
expect(result.current.isRunning).toBe(false);
expect(result.current.isPaused).toBe(false);
});

it('advanceStep POSTs to /scenario/advance-step', async () => {
(globalThis.fetch as Mock).mockResolvedValue({ ok: true });

const { result } = renderHook(() => useScenarioRunner());

await act(async () => {
await result.current.advanceStep();
});

expect(globalThis.fetch).toHaveBeenCalledWith('/scenario/advance-step', { method: 'POST' });
});

it('runs scenario successfully', async () => {
Expand Down
Loading
Loading