|
2 | 2 |
|
3 | 3 | from __future__ import annotations |
4 | 4 |
|
| 5 | +import datetime |
| 6 | +from typing import Optional |
| 7 | + |
5 | 8 | import pytest |
6 | 9 |
|
7 | 10 | from runloop_api_client.sdk import AsyncRunloopSDK |
| 11 | +from runloop_api_client.sdk.async_scenario import AsyncScenario |
8 | 12 |
|
9 | 13 | pytestmark = [pytest.mark.smoketest] |
10 | 14 |
|
11 | 15 | TWO_MINUTE_TIMEOUT = 120 |
12 | 16 | FIVE_MINUTE_TIMEOUT = 300 |
13 | 17 |
|
14 | 18 |
|
| 19 | +async def find_scenario_by_name_async(sdk_client: AsyncRunloopSDK, name: str) -> Optional[AsyncScenario]: |
| 20 | + """Find an existing scenario by name, returns None if not found.""" |
| 21 | + scenarios = await sdk_client.scenario.list(limit=100) |
| 22 | + for scenario in scenarios: |
| 23 | + info = await scenario.get_info() |
| 24 | + if info.name == name: |
| 25 | + return scenario |
| 26 | + return None |
| 27 | + |
| 28 | + |
15 | 29 | class TestAsyncScenarioRetrieval: |
16 | 30 | """Test async scenario retrieval operations.""" |
17 | 31 |
|
@@ -124,3 +138,136 @@ async def test_scenario_run_and_await_env_ready(self, async_sdk_client: AsyncRun |
124 | 138 | await run.cancel() |
125 | 139 | except Exception: |
126 | 140 | pass |
| 141 | + |
| 142 | + |
| 143 | +class TestAsyncScenarioBuilder: |
| 144 | + """Test async scenario builder operations. |
| 145 | +
|
| 146 | + These tests use fixed scenario names and reuse existing scenarios when possible |
| 147 | + to avoid accumulating test scenarios (since scenario deletion is not yet available). |
| 148 | + """ |
| 149 | + |
| 150 | + # Metadata for identifying test scenarios that can be cleaned up later |
| 151 | + CLEANUP_METADATA = {"smoketest": "true", "sdk_smoketest": "true", "cleanup_safe": "true"} |
| 152 | + |
| 153 | + # Fixed scenario names for reuse across test runs |
| 154 | + BASIC_SCENARIO_NAME = "sdk-smoketest-async-builder-basic" |
| 155 | + ENV_SCENARIO_NAME = "sdk-smoketest-async-builder-env" |
| 156 | + FLUENT_SCENARIO_NAME = "sdk-smoketest-async-builder-fluent" |
| 157 | + |
| 158 | + @pytest.mark.timeout(FIVE_MINUTE_TIMEOUT) |
| 159 | + async def test_builder_create_scenario(self, async_sdk_client: AsyncRunloopSDK) -> None: |
| 160 | + """Test creating a scenario with the async builder. |
| 161 | +
|
| 162 | + Uses a fixed name and reuses existing scenario if found. |
| 163 | + """ |
| 164 | + # Check if scenario already exists |
| 165 | + existing = await find_scenario_by_name_async(async_sdk_client, self.BASIC_SCENARIO_NAME) |
| 166 | + if existing: |
| 167 | + # Scenario exists, update metadata to exercise update path |
| 168 | + updated_metadata = { |
| 169 | + **self.CLEANUP_METADATA, |
| 170 | + "last_smoketest_run": datetime.datetime.now(datetime.timezone.utc).isoformat(), |
| 171 | + } |
| 172 | + await existing.update(metadata=updated_metadata) |
| 173 | + info = await existing.get_info() |
| 174 | + assert info.name == self.BASIC_SCENARIO_NAME |
| 175 | + assert info.input_context is not None |
| 176 | + return |
| 177 | + |
| 178 | + # Create new scenario |
| 179 | + builder = async_sdk_client.scenario.builder(self.BASIC_SCENARIO_NAME) |
| 180 | + builder.with_problem_statement("This is a test scenario created by the async SDK builder smoke test.") |
| 181 | + builder.add_bash_scorer( |
| 182 | + "test-scorer", |
| 183 | + bash_script="echo 'score=1.0'", |
| 184 | + weight=1.0, |
| 185 | + ) |
| 186 | + builder.with_metadata(self.CLEANUP_METADATA) |
| 187 | + |
| 188 | + scenario = await builder.push() |
| 189 | + |
| 190 | + assert scenario.id is not None |
| 191 | + info = await scenario.get_info() |
| 192 | + assert info.name == self.BASIC_SCENARIO_NAME |
| 193 | + assert info.input_context is not None |
| 194 | + |
| 195 | + @pytest.mark.timeout(FIVE_MINUTE_TIMEOUT) |
| 196 | + async def test_builder_with_environment(self, async_sdk_client: AsyncRunloopSDK) -> None: |
| 197 | + """Test creating a scenario with environment configuration. |
| 198 | +
|
| 199 | + Uses a fixed name and reuses existing scenario if found. |
| 200 | + """ |
| 201 | + # Check if scenario already exists |
| 202 | + existing = await find_scenario_by_name_async(async_sdk_client, self.ENV_SCENARIO_NAME) |
| 203 | + if existing: |
| 204 | + # Update metadata to exercise update path |
| 205 | + updated_metadata = { |
| 206 | + **self.CLEANUP_METADATA, |
| 207 | + "last_smoketest_run": datetime.datetime.now(datetime.timezone.utc).isoformat(), |
| 208 | + } |
| 209 | + await existing.update(metadata=updated_metadata) |
| 210 | + info = await existing.get_info() |
| 211 | + assert info.name == self.ENV_SCENARIO_NAME |
| 212 | + return |
| 213 | + |
| 214 | + # Get a blueprint to use |
| 215 | + blueprints = await async_sdk_client.blueprint.list(limit=1) |
| 216 | + if not blueprints: |
| 217 | + pytest.skip("No blueprints available for environment test") |
| 218 | + |
| 219 | + blueprint_id = blueprints[0].id |
| 220 | + |
| 221 | + builder = ( |
| 222 | + async_sdk_client.scenario.builder(self.ENV_SCENARIO_NAME) |
| 223 | + .from_blueprint_id(blueprint_id) |
| 224 | + .with_working_directory("/workspace") |
| 225 | + .with_problem_statement("Test scenario with environment configuration.") |
| 226 | + .add_command_scorer("check", command="echo 'score=1.0'", weight=1.0) |
| 227 | + .with_metadata(self.CLEANUP_METADATA) |
| 228 | + ) |
| 229 | + |
| 230 | + scenario = await builder.push() |
| 231 | + |
| 232 | + info = await scenario.get_info() |
| 233 | + assert info.name == self.ENV_SCENARIO_NAME |
| 234 | + if info.environment: |
| 235 | + assert info.environment.blueprint_id == blueprint_id |
| 236 | + |
| 237 | + @pytest.mark.timeout(FIVE_MINUTE_TIMEOUT) |
| 238 | + async def test_builder_fluent_api(self, async_sdk_client: AsyncRunloopSDK) -> None: |
| 239 | + """Test async builder fluent API chaining. |
| 240 | +
|
| 241 | + Uses a fixed name and reuses existing scenario if found. |
| 242 | + """ |
| 243 | + # Check if scenario already exists |
| 244 | + existing = await find_scenario_by_name_async(async_sdk_client, self.FLUENT_SCENARIO_NAME) |
| 245 | + if existing: |
| 246 | + # Update metadata to exercise update path |
| 247 | + updated_metadata = { |
| 248 | + **self.CLEANUP_METADATA, |
| 249 | + "last_smoketest_run": datetime.datetime.now(datetime.timezone.utc).isoformat(), |
| 250 | + "test_type": "fluent_api", |
| 251 | + } |
| 252 | + await existing.update(metadata=updated_metadata) |
| 253 | + info = await existing.get_info() |
| 254 | + assert info.name == self.FLUENT_SCENARIO_NAME |
| 255 | + if info.scoring_contract and info.scoring_contract.scoring_function_parameters: |
| 256 | + assert len(info.scoring_contract.scoring_function_parameters) == 2 |
| 257 | + return |
| 258 | + |
| 259 | + # Create new scenario with fluent API |
| 260 | + scenario = await ( |
| 261 | + async_sdk_client.scenario.builder(self.FLUENT_SCENARIO_NAME) |
| 262 | + .with_problem_statement("Async fluent API test scenario.") |
| 263 | + .with_additional_context({"hint": "This is a hint"}) |
| 264 | + .add_bash_scorer("scorer1", bash_script="echo 'score=0.5'", weight=1.0) |
| 265 | + .add_command_scorer("scorer2", command="echo 'score=1.0'", weight=2.0) |
| 266 | + .with_metadata({**self.CLEANUP_METADATA, "test_type": "fluent_api"}) |
| 267 | + .push() |
| 268 | + ) |
| 269 | + |
| 270 | + info = await scenario.get_info() |
| 271 | + assert info.name == self.FLUENT_SCENARIO_NAME |
| 272 | + if info.scoring_contract and info.scoring_contract.scoring_function_parameters: |
| 273 | + assert len(info.scoring_contract.scoring_function_parameters) == 2 |
0 commit comments