Files
centdix b0ddcf31e4 ci: add path-gated AI agent + ai_evals smoke workflows (#9640)
* ci: add path-gated AI agent integration tests workflow

Runs integration_tests/ai_agent_tests against real LLM providers
(Anthropic/OpenAI/Google) only when AI-agent backend code or the tests
change, since runs make paid LLM calls. Adds a conftest fixture that
skips provider-parametrized cases whose API keys are absent, so CI
exercises only the providers it has secrets for.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: add path-gated ai_evals global-mode smoke workflow

Runs the global AI chat eval (global-test1) across one cheap model per
provider (Anthropic/OpenAI/Google/DeepSeek) only when the eval harness or
copilot chat code change, since runs make paid LLM calls. Builds Windmill
CE from source as the AI proxy; global tools/drafts run in the Vitest
bridge. Gates on the deterministic draft pipeline (run succeeded +
produced a draft + used write_script), not the variable LLM judge score.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: run AI smokes on PR ready-for-review instead of every push

Switch the pull_request trigger from `synchronize` (every commit) to
`ready_for_review`, with a job guard skipping draft PRs, so the paid LLM
runs only fire when a PR is marked ready to merge (plus push-to-main and
manual dispatch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ai_evals): lazily load cli mode so non-cli evals skip the cli toolchain

The entrypoint eagerly imported modes/cli, which pulls the wmill CLI
guidance modules and their JSR deps (@cliffy/*). Global/flow/script/app
runs then crashed with "Cannot find module '@cliffy/ansi/colors'" when
the cli workspace deps were not installed. Import createCliModeRunner
dynamically inside runCliBenchmark instead.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(ai_agent): raise low max_completion_tokens to OpenAI's 16 minimum

OpenAI's /v1/responses rejects max_output_tokens < 16 with a 400, failing
test_low_max_tokens for openai. 16 still exercises a truncated response.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci: run ai_evals workflow on Node 22 for the frontend undici 8.x dep

The Vitest bridge loads frontend/node_modules/undici@8.x, which requires
Node >=22.19; Node 20 failed with "webidl.util.markAsUncloneable is not a
function" when loading vitest.config.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ai_evals): run frontend evals autonomously + give global-test1 more turns

Frontend evals (flow/script/app/global) ran the production chat prompt, which
assumes an interactive human — so cheaper models burned their turn budget
asking for confirmation, waiting for approval, or presenting a plan, sometimes
hitting maxTurns without producing a draft. Append a shared autonomy note in
baseEvalRunner (the path all frontend modes share, mirroring cli mode): act
directly on clear requests; only ask on genuinely ambiguous ones (preserving
the askUserQuestion cases). Also raise global-test1's maxTurns 8 -> 10 so a
model that over-explores still converges.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(ai_evals): watch draft/prompt deps outside copilot/

The global eval runs production frontend code in-process, so the smoke's
behavior depends on files outside frontend/src/lib/components/copilot/**:
the draft model (userDraft.svelte.ts, userDraftDbSyncer.svelte.ts), script
inference (infer.ts), and the chat system prompts ($system_prompts ->
system_prompts/auto-generated). Add them to both push and PR path filters so
a change there actually triggers the smoke that gates on draft production.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: skip direct provider tests without credentials

* feat: add ai evals skip judge flag

* fix: simplify ai evals ci gate

* fix: simplify ai evals smoke gate

* fix: handle ai eval workflow triggers

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:41:31 +02:00

693 lines
24 KiB
Python

"""
Test fixtures and utilities for AI agent integration tests.
"""
import os
import uuid
from pathlib import Path
from typing import Any
import httpx
import pytest
from dotenv import load_dotenv
# Load .env file from the test directory
load_dotenv(Path(__file__).parent / ".env")
# Constants for S3/user_images tests
TEST_IMAGE_PATH = Path(__file__).parent / "test_image.webp"
TEST_IMAGE_S3_KEY = "test_images/test_image.webp"
# Env vars each provider needs before its cases can run. Cases for a provider
# whose keys are absent are skipped instead of failed, so a CI run (or a local
# dev) can exercise only the providers it has credentials for.
PROVIDER_ENV_REQUIREMENTS: dict[str, list[str]] = {
"openai": ["OPENAI_API_KEY"],
"azure_openai": ["AZURE_OPENAI_API_KEY", "AZURE_OPENAI_BASE_URL"],
"anthropic": ["ANTHROPIC_API_KEY"],
"google_ai": ["GOOGLE_AI_API_KEY"],
"openrouter": ["OPENROUTER_API_KEY"],
"bedrock": ["BEDROCK_API_KEY"],
"bedrock_api_key": ["BEDROCK_API_KEY"],
"bedrock_iam": ["BEDROCK_IAM_ACCESS_KEY_ID", "BEDROCK_IAM_SECRET_ACCESS_KEY"],
"bedrock_iam_session": [
"BEDROCK_SESSION_ACCESS_KEY_ID",
"BEDROCK_SESSION_SECRET_ACCESS_KEY",
"BEDROCK_SESSION_TOKEN",
],
"bedrock_env": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
}
def pytest_configure(config):
config.addinivalue_line(
"markers",
"requires_provider(provider): skip test when the provider's credentials are absent",
)
def _provider_name_from_item(item) -> str | None:
callspec = getattr(item, "callspec", None)
if callspec is None:
marker = item.get_closest_marker("requires_provider")
if marker is None:
return None
provider = marker.args[0] if marker.args else marker.kwargs.get("provider")
else:
provider = callspec.params.get("provider_config")
if isinstance(provider, str):
return provider
if isinstance(provider, dict) and isinstance(provider.get("name"), str):
return provider["name"]
return None
def _provider_skip_reason(provider_name: str) -> str | None:
required = PROVIDER_ENV_REQUIREMENTS.get(provider_name, [])
missing = [name for name in required if not os.environ.get(name)]
if missing:
return f"{provider_name}: missing {', '.join(missing)}"
return None
def pytest_collection_modifyitems(config, items):
for item in items:
provider_name = _provider_name_from_item(item)
if provider_name is None:
continue
reason = _provider_skip_reason(provider_name)
if reason is not None:
item.add_marker(pytest.mark.skip(reason=reason))
@pytest.fixture(autouse=True)
def skip_provider_without_credentials(request):
"""Skip dynamic provider cases when that provider's keys are not set."""
provider_name = _provider_name_from_item(request.node)
if provider_name is None:
return
reason = _provider_skip_reason(provider_name)
if reason is not None:
pytest.skip(reason)
class AIAgentTestClient:
"""HTTP client for testing AI agents via the preview_flow endpoint."""
def __init__(
self,
base_url: str = "http://localhost:8000",
workspace: str = "integration-tests",
):
self.base_url = base_url
self.workspace = workspace
self._token: str | None = None
self._client: httpx.Client | None = None
def _login(self) -> str:
"""Login and get auth token."""
with httpx.Client(base_url=self.base_url) as client:
response = client.post(
"/api/auth/login",
json={
"email": "admin@windmill.dev",
"password": "changeme",
},
)
if response.status_code // 100 != 2:
raise Exception(f"Login failed: {response.content.decode()}")
return response.content.decode()
def _ensure_workspace(self):
"""Create workspace if it doesn't exist."""
exists = self._client.post(
"/api/workspaces/exists",
json={"id": self.workspace},
)
if exists.status_code // 100 == 2 and exists.content.decode() == "true":
return
response = self._client.post(
"/api/workspaces/create",
json={"id": self.workspace, "name": self.workspace},
)
if response.status_code // 100 != 2:
raise Exception(f"Failed to create workspace: {response.content.decode()}")
def connect(self):
"""Initialize connection and authenticate."""
self._token = self._login()
self._client = httpx.Client(
base_url=self.base_url,
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self._token}",
},
timeout=180.0, # 3 minutes for AI operations
)
self._ensure_workspace()
print(f"Connected to {self.base_url}, workspace: {self.workspace}")
def close(self):
"""Close the HTTP client."""
if self._client:
self._client.post("/api/auth/logout")
self._client.close()
self._client = None
def run_preview_flow(
self,
flow_value: dict[str, Any],
args: dict[str, Any] | None = None,
memory_id: str | None = None,
timeout_secs: int | None = None,
) -> dict[str, Any]:
"""
Run a preview flow and wait for the result.
Args:
flow_value: The FlowValue definition (modules, chat_input_enabled, etc.)
args: Flow input arguments
memory_id: Optional memory ID for chat conversations
timeout_secs: Optional timeout in seconds
Returns:
The job result as a dictionary
"""
if memory_id is None:
memory_id = str(uuid.uuid4())
payload = {
"value": flow_value,
"args": args or {},
}
params = {"memory_id": memory_id}
if timeout_secs:
params["timeout"] = timeout_secs
response = self._client.post(
f"/api/w/{self.workspace}/jobs/run_wait_result/preview_flow",
params=params,
json=payload,
)
if response.status_code // 100 != 2:
raise Exception(
f"Preview flow failed with status {response.status_code}: "
f"{response.content.decode()}"
)
return response.json()
def create_variable(self, path: str, value: str, is_secret: bool = True):
"""Create a variable (typically for API keys)."""
response = self._client.post(
f"/api/w/{self.workspace}/variables/create",
json={
"path": path,
"value": value,
"description": "API key for AI agent testing",
"is_secret": is_secret,
},
)
if response.status_code // 100 != 2:
error = response.content.decode()
# Ignore if variable already exists
if "already exists" not in error.lower():
raise Exception(f"Failed to create variable: {error}")
def create_resource(self, path: str, resource_type: str, value: dict[str, Any]):
"""Create a resource that can reference variables."""
response = self._client.post(
f"/api/w/{self.workspace}/resources/create",
json={
"path": path,
"resource_type": resource_type,
"value": value,
"description": "AI provider resource for testing",
},
)
if response.status_code // 100 != 2:
error = response.content.decode()
# Ignore if resource already exists
if "already exists" not in error.lower():
raise Exception(f"Failed to create resource: {error}")
def script_exists(self, path: str) -> bool:
"""Check if a script exists at the given path."""
response = self._client.get(
f"/api/w/{self.workspace}/scripts/exists/p/{path}",
)
return response.status_code == 200 and response.content.decode() == "true"
def create_script(
self,
path: str,
content: str,
language: str = "bun",
summary: str = "",
description: str = "",
schema: dict[str, Any] | None = None,
):
"""Create a script in the workspace."""
# Check if script already exists
if self.script_exists(path):
return
payload = {
"path": path,
"summary": summary,
"description": description,
"content": content,
"schema": schema or {"type": "object", "properties": {}, "required": []},
"is_template": False,
"language": language,
"kind": "script",
}
response = self._client.post(
f"/api/w/{self.workspace}/scripts/create",
json=payload,
)
if response.status_code // 100 != 2:
error = response.content.decode()
# Ignore if script already exists
if "already exists" not in error.lower():
raise Exception(f"Failed to create script: {error}")
def upload_s3_file(self, s3_key: str, file_content: bytes, content_type: str = "image/png") -> dict:
"""Upload a file to S3 storage via Windmill API."""
response = self._client.post(
f"/api/w/{self.workspace}/job_helpers/upload_s3_file",
params={"file_key": s3_key},
content=file_content,
headers={"Content-Type": content_type},
)
response.raise_for_status()
return response.json()
def delete_s3_file(self, s3_key: str, storage: str | None = None) -> dict:
"""Delete a file from S3 storage via Windmill API."""
params = {"file_key": s3_key}
if storage:
params["storage"] = storage
response = self._client.delete(
f"/api/w/{self.workspace}/job_helpers/delete_s3_file",
params=params,
)
if response.status_code // 100 != 2:
raise Exception(f"Failed to delete S3 file: {response.content.decode()}")
return response.json()
def configure_s3_storage(self, s3_resource_path: str):
"""Configure workspace large file storage with S3."""
response = self._client.post(
f"/api/w/{self.workspace}/workspaces/edit_large_file_storage_config",
json={
"large_file_storage": {
"type": "S3Storage",
"s3_resource_path": s3_resource_path,
"public_resource": False,
"secondary_storage": {},
"advanced_permissions": [
{"allow": "read,write,delete", "pattern": "windmill_uploads/*"},
{"allow": "read,write,delete,list", "pattern": "u/{username}/**/*"},
{"allow": "read,write,delete,list", "pattern": "g/{group}/**/*"},
{"allow": "read,write,delete,list", "pattern": "f/{folder_write}/**/*"},
{"allow": "read,list", "pattern": "f/{folder_read}/**/*"},
{"allow": "", "pattern": "**/*"},
],
}
},
)
if response.status_code // 100 != 2:
raise Exception(f"Failed to configure S3 storage: {response.content.decode()}")
def create_ai_agent_flow(
provider_input_transform: dict[str, Any],
system_prompt: str = "You are a helpful assistant. Be concise.",
tools: list[dict[str, Any]] | None = None,
output_schema: dict[str, Any] | None = None,
streaming: bool | None = None,
include_user_images: bool = False,
output_type: str | None = None,
temperature: float | None = None,
max_completion_tokens: int | None = None,
context_length: int | None = None,
) -> dict[str, Any]:
"""
Create a FlowValue for an AI agent.
Args:
provider_input_transform: The input transform for the provider field
system_prompt: System prompt for the AI agent
tools: Optional list of tool definitions
output_schema: Optional JSON schema for structured output
streaming: Optional flag to enable streaming responses
include_user_images: If True, adds user_images input from flow_input
output_type: Optional output type ("text" or "image")
temperature: Optional temperature for sampling (0.0-2.0)
max_completion_tokens: Optional maximum tokens for completion
Returns:
A FlowValue dictionary ready to be sent to preview_flow
"""
input_transforms = {
"provider": provider_input_transform,
"system_prompt": {"type": "static", "value": system_prompt},
"user_message": {"type": "javascript", "expr": "flow_input.user_message"},
}
# Add output_schema if provided
if output_schema is not None:
input_transforms["output_schema"] = {"type": "static", "value": output_schema}
# Add streaming if provided
if streaming is not None:
input_transforms["streaming"] = {"type": "static", "value": streaming}
# Add user_images if enabled
if include_user_images:
input_transforms["user_images"] = {"type": "javascript", "expr": "flow_input.user_images"}
# Add output_type if provided
if output_type is not None:
input_transforms["output_type"] = {"type": "static", "value": output_type}
# Add temperature if provided
if temperature is not None:
input_transforms["temperature"] = {"type": "static", "value": temperature}
# Add max_completion_tokens if provided
if max_completion_tokens is not None:
input_transforms["max_completion_tokens"] = {"type": "static", "value": max_completion_tokens}
# Add context_length if provided
if context_length is not None:
input_transforms["memory"] = {"type": "static", "value": {
"kind": "auto",
"context_length": context_length,
}}
module_value = {
"type": "aiagent",
"input_transforms": input_transforms,
"tools": tools or [],
}
return {
"modules": [
{
"id": "a",
"value": module_value,
}
],
"chat_input_enabled": True,
}
def create_rawscript_tool(
tool_id: str,
content: str,
params: list[str],
language: str = "bun"
) -> dict[str, Any]:
"""
Create a tool using rawscript (inline script content).
Args:
tool_id: Unique ID for the tool
content: The script content
params: List of parameter names (each gets type: ai so the agent provides values)
language: Script language (default: bun)
Returns:
A tool definition dictionary
"""
input_transforms = {param: {"type": "ai"} for param in params}
return {
"id": tool_id,
"summary": tool_id,
"value": {
"tool_type": "flowmodule",
"type": "rawscript",
"content": content,
"language": language,
"input_transforms": input_transforms,
},
}
def create_websearch_tool() -> dict[str, Any]:
"""
Create a websearch tool for AI agents.
Returns:
A websearch tool definition dictionary
"""
return {
"id": "websearch",
"summary": "Web Search",
"value": {
"tool_type": "websearch",
},
}
def create_script_tool(
tool_id: str,
script_path: str,
params: list[str],
) -> dict[str, Any]:
"""
Create a tool that references an existing script in the workspace.
Args:
tool_id: Unique ID for the tool
script_path: Path to the script in the workspace (e.g., "u/admin/sum_script")
params: List of parameter names (each gets type: ai so the agent provides values)
Returns:
A tool definition dictionary
"""
input_transforms = {param: {"type": "ai"} for param in params}
return {
"id": tool_id,
"summary": tool_id,
"value": {
"tool_type": "flowmodule",
"type": "script",
"path": script_path,
"input_transforms": input_transforms,
},
}
@pytest.fixture(scope="session")
def client():
"""Create and return an AI agent test client."""
client = AIAgentTestClient(
base_url=os.environ.get("WINDMILL_URL", "http://localhost:8000"),
workspace=os.environ.get("WINDMILL_WORKSPACE", "integration-tests"),
)
client.connect()
yield client
client.close()
@pytest.fixture(scope="session")
def setup_providers(client):
"""
Set up API key variables and resources from environment variables.
Expects the following environment variables to be set:
- OPENAI_API_KEY
- ANTHROPIC_API_KEY
- GOOGLE_AI_API_KEY
- OPENROUTER_API_KEY
- BEDROCK_API_KEY (optional)
- BEDROCK_IAM_ACCESS_KEY_ID and BEDROCK_IAM_SECRET_ACCESS_KEY (optional, for IAM Bedrock tests)
- BEDROCK_SESSION_ACCESS_KEY_ID, BEDROCK_SESSION_SECRET_ACCESS_KEY, BEDROCK_SESSION_TOKEN
(optional, for IAM session Bedrock tests)
- AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY (optional, for environment fallback tests)
- AWS_SESSION_TOKEN (optional, if environment fallback uses temporary credentials)
- BEDROCK_REGION (optional, defaults to us-east-1)
"""
# OpenAI
if os.environ.get("OPENAI_API_KEY"):
client.create_variable("u/admin/openai_api_key", os.environ["OPENAI_API_KEY"])
client.create_resource("u/admin/openai", "openai", {
"api_key": "$var:u/admin/openai_api_key"
})
# Azure OpenAI
if os.environ.get("AZURE_OPENAI_API_KEY") and os.environ.get("AZURE_OPENAI_BASE_URL"):
client.create_variable("u/admin/azure_openai_api_key", os.environ["AZURE_OPENAI_API_KEY"])
client.create_resource("u/admin/azure_openai", "azure_openai", {
"apiKey": "$var:u/admin/azure_openai_api_key",
"baseUrl": os.environ["AZURE_OPENAI_BASE_URL"],
})
# Anthropic
if os.environ.get("ANTHROPIC_API_KEY"):
client.create_variable("u/admin/anthropic_api_key", os.environ["ANTHROPIC_API_KEY"])
client.create_resource("u/admin/anthropic", "anthropic", {
"apiKey": "$var:u/admin/anthropic_api_key"
})
# Google AI
if os.environ.get("GOOGLE_AI_API_KEY"):
client.create_variable("u/admin/google_ai_api_key", os.environ["GOOGLE_AI_API_KEY"])
client.create_resource("u/admin/googleai", "googleai", {
"api_key": "$var:u/admin/google_ai_api_key"
})
# OpenRouter
if os.environ.get("OPENROUTER_API_KEY"):
client.create_variable("u/admin/openrouter_api_key", os.environ["OPENROUTER_API_KEY"])
client.create_resource("u/admin/openrouter", "openrouter", {
"api_key": "$var:u/admin/openrouter_api_key"
})
bedrock_region = os.environ.get("BEDROCK_REGION", "us-east-1")
# Bedrock (using apiKey approach)
if os.environ.get("BEDROCK_API_KEY"):
client.create_variable("u/admin/bedrock_api_key", os.environ["BEDROCK_API_KEY"])
client.create_resource("u/admin/bedrock", "aws_bedrock", {
"apiKey": "$var:u/admin/bedrock_api_key",
"region": bedrock_region
})
# Bedrock IAM credentials without session token
if os.environ.get("BEDROCK_IAM_ACCESS_KEY_ID") and os.environ.get("BEDROCK_IAM_SECRET_ACCESS_KEY"):
client.create_variable(
"u/admin/bedrock_iam_access_key_id",
os.environ["BEDROCK_IAM_ACCESS_KEY_ID"],
)
client.create_variable(
"u/admin/bedrock_iam_secret_access_key",
os.environ["BEDROCK_IAM_SECRET_ACCESS_KEY"],
)
client.create_resource("u/admin/bedrock_iam", "aws_bedrock", {
"awsAccessKeyId": "$var:u/admin/bedrock_iam_access_key_id",
"awsSecretAccessKey": "$var:u/admin/bedrock_iam_secret_access_key",
"region": bedrock_region
})
# Bedrock IAM credentials with session token
if (
os.environ.get("BEDROCK_SESSION_ACCESS_KEY_ID")
and os.environ.get("BEDROCK_SESSION_SECRET_ACCESS_KEY")
and os.environ.get("BEDROCK_SESSION_TOKEN")
):
client.create_variable(
"u/admin/bedrock_session_access_key_id",
os.environ["BEDROCK_SESSION_ACCESS_KEY_ID"],
)
client.create_variable(
"u/admin/bedrock_session_secret_access_key",
os.environ["BEDROCK_SESSION_SECRET_ACCESS_KEY"],
)
client.create_variable(
"u/admin/bedrock_session_token",
os.environ["BEDROCK_SESSION_TOKEN"],
)
client.create_resource("u/admin/bedrock_iam_session", "aws_bedrock", {
"awsAccessKeyId": "$var:u/admin/bedrock_session_access_key_id",
"awsSecretAccessKey": "$var:u/admin/bedrock_session_secret_access_key",
"awsSessionToken": "$var:u/admin/bedrock_session_token",
"region": bedrock_region
})
# Bedrock using environment credentials fallback
# This resource intentionally omits explicit credentials.
client.create_resource("u/admin/bedrock_env", "aws_bedrock", {
"region": bedrock_region
})
# DeepWiki MCP resource (always created for MCP tool tests)
client.create_resource("u/admin/deepwiki", "mcp", {
"url": "https://mcp.deepwiki.com/mcp",
"name": "deepwiki"
})
# Create sum script for workspace script tool tests
client.create_script(
path="u/admin/sum_script",
content="""export function main(a: number, b: number): number {
return a + b;
}
""",
language="bun",
summary="Sum two numbers",
description="A simple script that adds two numbers together",
schema={
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"a": {
"type": "number",
"description": "First number to add",
},
"b": {
"type": "number",
"description": "Second number to add",
},
},
"required": ["a", "b"],
},
)
yield
@pytest.fixture(scope="session")
def setup_s3_storage(client):
"""
Set up MinIO S3 storage for user_images tests and upload test image.
Expects the following environment variables to be set:
- MINIO_ACCESS_KEY
- MINIO_SECRET_KEY
"""
access_key = os.environ.get("MINIO_ACCESS_KEY")
secret_key = os.environ.get("MINIO_SECRET_KEY")
if not access_key or not secret_key:
pytest.skip("MINIO_ACCESS_KEY and MINIO_SECRET_KEY required for S3 tests")
# 1. Create variable for secret key
client.create_variable("u/admin/minio_secret_key", secret_key, is_secret=True)
# 2. Create S3 resource for MinIO
client.create_resource("u/admin/minio_s3", "s3", {
"port": 9000,
"bucket": "wmill",
"region": "fr",
"useSSL": False,
"endPoint": "localhost",
"accessKey": access_key,
"pathStyle": True,
"secretKey": "$var:u/admin/minio_secret_key",
})
# 3. Configure workspace large file storage
client.configure_s3_storage("$res:u/admin/minio_s3")
# 4. Upload test image to S3 right after setup
if TEST_IMAGE_PATH.exists():
with open(TEST_IMAGE_PATH, "rb") as f:
image_content = f.read()
client.upload_s3_file(TEST_IMAGE_S3_KEY, image_content)
else:
pytest.skip(f"Test image not found at {TEST_IMAGE_PATH}")
yield TEST_IMAGE_S3_KEY