Skip to content
Merged
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
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions src/openutm_verification/server/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,19 @@ async def list_scenarios():
return [f.stem for f in path.glob("*.yaml")]


@scenario_router.get("/api/suites")
async def list_suites(runner: Any = Depends(get_runner)):
"""Return suite-to-scenario mapping from the loaded configuration."""
config = runner.config
result: dict[str, list[str]] = {}
for suite_name, suite_config in config.suites.items():
if suite_config.scenarios:
result[suite_name] = [s.name for s in suite_config.scenarios]
else:
result[suite_name] = []
return result


@scenario_router.get("/api/scenarios/{scenario}")
async def get_scenario(scenario: str):
"""Get the content of a specific scenario."""
Expand Down
2 changes: 1 addition & 1 deletion web-editor/src/components/ScenarioEditor/CustomNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export const CustomNode = ({ data, selected }: NodeProps<Node<NodeData>>) => {
)}
</div>
{data.status && (
<div className={styles.statusIndicator}>
<div className={styles.statusIndicator} data-testid={`status-${data.status}`}>
{data.status === 'success' && (
<CheckCircle size={16} color="var(--success)" />
)}
Expand Down
152 changes: 124 additions & 28 deletions web-editor/src/components/ScenarioEditor/ScenarioList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { FileText, MessageCircleQuestionMark } from 'lucide-react';
import { useState, useEffect, useMemo } from 'react';
import { FileText, MessageCircleQuestionMark, ChevronDown, ChevronRight, FolderOpen } from 'lucide-react';
import styles from '../../styles/Toolbox.module.css';
import type { Operation, ScenarioDefinition, NodeData, ScenarioConfig } from '../../types/scenario';
import type { Node, Edge } from '@xyflow/react';
Expand All @@ -13,17 +13,82 @@ interface ScenarioListProps {
refreshKey?: number;
}

type SuiteMap = Record<string, string[]>;

export const ScenarioList = ({ onLoadScenario, operations, currentScenarioName, onSelectScenario, refreshKey = 0 }: ScenarioListProps) => {
const [scenarios, setScenarios] = useState<string[]>([]);
const [suites, setSuites] = useState<SuiteMap>({});
const [loading, setLoading] = useState(false);
const [collapsedSuites, setCollapsedSuites] = useState<Set<string>>(new Set());

useEffect(() => {
fetch('/api/scenarios')
.then(res => res.json())
.then((data: string[]) => setScenarios(data.sort()))
.catch(err => console.error('Failed to load scenarios:', err));
const fetchScenarios = async (): Promise<string[]> => {
const res = await fetch('/api/scenarios');
if (!res.ok) return [];
const data: unknown = await res.json();
return Array.isArray(data) && data.every(item => typeof item === 'string') ? data : [];
};

const fetchSuites = async (): Promise<SuiteMap> => {
const res = await fetch('/api/suites');
if (!res.ok) return {};
const data: unknown = await res.json();
if (typeof data !== 'object' || data === null || Array.isArray(data)) return {};
const result: SuiteMap = {};
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
if (Array.isArray(value) && value.every(item => typeof item === 'string')) {
result[key] = value;
}
}
return result;
};

Promise.all([
fetchScenarios(),
fetchSuites().catch(() => ({} as SuiteMap)),
]).then(([scenarioList, suiteMap]) => {
setScenarios(scenarioList.sort());
setSuites(suiteMap);
}).catch(err => console.error('Failed to load scenarios:', err));
Comment on lines +46 to +52
Copy link

Copilot AI Feb 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The /api/scenarios and /api/suites fetches assume a successful (2xx) response and the expected JSON shape. If either endpoint returns a non-OK status with an error payload (e.g., FastAPI {detail: ...}), scenarioList.sort() or suiteNames.flatMap(s => suites[s]) can throw at runtime. Consider checking res.ok before parsing, and validating/coercing the parsed JSON into string[] / Record<string, string[]>, falling back to [] / {} on mismatch.

Suggested change
Promise.all([
fetch('/api/scenarios').then(res => res.json()),
fetch('/api/suites').then(res => res.json()).catch(() => ({})),
]).then(([scenarioList, suiteMap]: [string[], SuiteMap]) => {
setScenarios(scenarioList.sort());
setSuites(suiteMap);
}).catch(err => console.error('Failed to load scenarios:', err));
const loadData = async () => {
try {
const [scenariosRes, suitesRes] = await Promise.all([
fetch('/api/scenarios'),
fetch('/api/suites'),
]);
let rawScenarios: unknown = [];
let rawSuites: unknown = {};
if (scenariosRes.ok) {
rawScenarios = await scenariosRes.json();
}
if (suitesRes.ok) {
rawSuites = await suitesRes.json();
}
// Coerce scenarios into string[]
const scenarioList: string[] = Array.isArray(rawScenarios)
? rawScenarios.filter((s): s is string => typeof s === 'string')
: [];
// Coerce suites into Record<string, string[]>
const suiteMap: SuiteMap = {};
if (rawSuites && typeof rawSuites === 'object' && !Array.isArray(rawSuites)) {
for (const [key, value] of Object.entries(rawSuites as Record<string, unknown>)) {
if (Array.isArray(value)) {
suiteMap[key] = value.filter((v): v is string => typeof v === 'string');
}
}
}
setScenarios(scenarioList.slice().sort());
setSuites(suiteMap);
} catch (err) {
console.error('Failed to load scenarios:', err);
setScenarios([]);
setSuites({});
}
};
void loadData();

Copilot uses AI. Check for mistakes.
}, [refreshKey]);

const hasSuites = Object.keys(suites).length > 0;

const groupedScenarios = useMemo(() => {
const suiteNames = Object.keys(suites).sort((a, b) => a.localeCompare(b));
const scenarioSet = new Set(scenarios);
const assigned = new Set(suiteNames.flatMap(s => suites[s]));
const ungrouped = scenarios.filter(s => !assigned.has(s));

const groups: { suite: string; label: string; items: string[] }[] = [];
for (const suite of suiteNames) {
const items = suites[suite]
.filter(name => scenarioSet.has(name))
.slice()
.sort((a, b) => a.localeCompare(b));
if (items.length > 0) {
groups.push({
suite,
label: suite.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
items,
});
}
}
if (ungrouped.length > 0) {
groups.push({ suite: '__ungrouped__', label: 'Other Scenarios', items: ungrouped.sort() });
}
return groups;
}, [scenarios, suites]);

const toggleSuite = (suite: string) => {
setCollapsedSuites(prev => {
const next = new Set(prev);
if (next.has(suite)) next.delete(suite);
else next.add(suite);
return next;
});
};

const handleLoad = async (filename: string) => {
if (loading) return;
setLoading(true);
Expand All @@ -44,34 +109,65 @@ export const ScenarioList = ({ onLoadScenario, operations, currentScenarioName,
}
};

const renderScenarioItem = (name: string) => (
<div
key={name}
className={styles.nodeItem}
onClick={() => handleLoad(name)}
role="button"
tabIndex={0}
title={name}
style={{
cursor: 'pointer',
opacity: loading ? 0.5 : 1,
borderColor: name === currentScenarioName ? 'var(--accent-primary)' : 'var(--border-color)',
backgroundColor: name === currentScenarioName ? 'var(--bg-secondary)' : 'var(--bg-primary)'
}}
>
<FileText size={16} color={name === currentScenarioName ? "var(--accent-primary)" : "#8b949e"} />
<span>{name.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}</span>
</div>
);

return (
<div>

<div className={styles.groupContent}>

<div style={{ padding: '8px', color: '#666', fontSize: '12px', marginBottom: '8px' }}>
<MessageCircleQuestionMark size={16} style={{ marginRight: '8px', color: '#666' }} />
Pre-built scenarios you can load to get started
{hasSuites ? 'Pre-built scenarios grouped by test suite' : 'Pre-built scenarios'}
</div>
{scenarios.map(name => (
<div
key={name}
className={styles.nodeItem}
onClick={() => handleLoad(name)}
role="button"
tabIndex={0}
title={name}
style={{
cursor: 'pointer',
opacity: loading ? 0.5 : 1,
borderColor: name === currentScenarioName ? 'var(--accent-primary)' : 'var(--border-color)',
backgroundColor: name === currentScenarioName ? 'var(--bg-secondary)' : 'var(--bg-primary)'
}}
>
<FileText size={16} color={name === currentScenarioName ? "var(--accent-primary)" : "#8b949e"} />
<span>{name.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}</span>
</div>
))}

{hasSuites ? (
groupedScenarios.map(({ suite, label, items }) => {
const isCollapsed = collapsedSuites.has(suite);
return (
<div key={suite} style={{ marginBottom: '4px' }}>
<button
type="button"
className={styles.groupHeader}
onClick={() => toggleSuite(suite)}
aria-expanded={!isCollapsed}
style={{ padding: '6px 4px', marginTop: 4, marginBottom: 4, background: 'none', border: 'none', width: '100%' }}
>
{isCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
<FolderOpen size={14} />
{label}
<span style={{ marginLeft: 'auto', fontSize: '11px', fontWeight: 400, opacity: 0.7 }}>
{items.length}
</span>
</button>
{!isCollapsed && (
<div style={{ paddingLeft: '8px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
{items.map(renderScenarioItem)}
</div>
)}
</div>
);
})
) : (
scenarios.map(renderScenarioItem)
)}

{scenarios.length === 0 && (
<div style={{ padding: '8px', color: '#666', fontSize: '12px' }}>
No scenarios found
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { CustomNode } from '../CustomNode';
import { ReactFlowProvider } from '@xyflow/react';
Expand Down Expand Up @@ -43,27 +43,16 @@ describe('CustomNode', () => {

it('renders success status icon', () => {
render(<CustomNode {...defaultProps} />, { wrapper });
// Check for CheckCircle icon (or button containing it)
const button = screen.getByTitle('Success');
expect(button).toBeInTheDocument();
expect(screen.getByTestId('status-success')).toBeInTheDocument();
});

it('calls onShowResult when status icon is clicked', () => {
render(<CustomNode {...defaultProps} />, { wrapper });
const button = screen.getByTitle('Success');
fireEvent.click(button);
expect(mockData.onShowResult).toHaveBeenCalledWith(mockData.result);
});

it('renders failure status', () => {
it('renders failure status icon', () => {
const failureProps = {
...defaultProps,
data: { ...mockData, status: 'failure' as const }
};
render(<CustomNode {...failureProps} />, { wrapper });
const button = screen.getByTitle('Failure');
expect(button).toBeInTheDocument();
// We could check for color or specific icon if we could query by icon
expect(screen.getByTestId('status-failure')).toBeInTheDocument();
});

it('applies selected style', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,34 +59,21 @@ describe('PropertiesPanel', () => {
expect(defaultProps.onUpdateParameter).toHaveBeenCalledWith('1', 'arg1', 'newValue');
});

it('resolves group step references correctly', () => {
const groupContainerNode: Node<NodeData> = {
id: 'group_container_1',
it('resolves step references correctly', () => {
const fetchNode: Node<NodeData> = {
id: 'fetch_node',
type: 'custom',
position: { x: 0, y: 0 },
data: {
label: '📦 group_1',
isGroupContainer: true,
parameters: []
}
};

const groupStep1: Node<NodeData> = {
id: 'group_container_1_step_0',
type: 'custom',
parentId: 'group_container_1',
position: { x: 0, y: 0 },
data: {
label: 'Fetch Data',
stepId: 'fetch',
parameters: []
}
};

const groupStep2: Node<NodeData> = {
id: 'group_container_1_step_1',
const submitNode: Node<NodeData> = {
id: 'submit_node',
type: 'custom',
parentId: 'group_container_1',
position: { x: 0, y: 100 },
data: {
label: 'Submit Data',
Expand All @@ -95,16 +82,17 @@ describe('PropertiesPanel', () => {
{
name: 'data',
type: 'object',
default: '${{ group.fetch.result }}'
default: { $ref: 'steps.fetch.result' }
}
]
}
};

const props = {
...defaultProps,
selectedNode: groupStep2,
allNodes: [groupContainerNode, groupStep1, groupStep2]
selectedNode: submitNode,
connectedNodes: [fetchNode],
allNodes: [fetchNode, submitNode]
};

render(<PropertiesPanel {...props} />);
Expand Down
Loading