Merge branch 'swiftyos/sdk' into swiftyos/integrations

This commit is contained in:
SwiftyOS
2025-07-08 17:28:31 +02:00
41 changed files with 1535 additions and 198 deletions

View File

@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
cache-key: ${{ steps.cache-key.outputs.key }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -115,7 +115,7 @@ jobs:
needs: setup
# Only run on dev branch pushes or PRs targeting dev
if: github.ref == 'refs/heads/dev' || github.base_ref == 'dev'
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -148,6 +148,7 @@ jobs:
onlyChanged: true
workingDir: autogpt_platform/frontend
token: ${{ secrets.GITHUB_TOKEN }}
exitOnceUploaded: true
test:
runs-on: ubuntu-latest
@@ -212,6 +213,8 @@ jobs:
- name: Run Playwright tests
run: pnpm test:no-build --project=${{ matrix.browser }}
env:
BROWSER_TYPE: ${{ matrix.browser }}
- name: Print Final Docker Compose logs
if: always()

View File

@@ -1,5 +1,6 @@
from backend.sdk import (
APIKeyCredentials,
BaseModel,
Block,
BlockCategory,
BlockOutput,
@@ -12,6 +13,41 @@ from backend.sdk import (
from ._config import exa
class CostBreakdown(BaseModel):
keywordSearch: float
neuralSearch: float
contentText: float
contentHighlight: float
contentSummary: float
class SearchBreakdown(BaseModel):
search: float
contents: float
breakdown: CostBreakdown
class PerRequestPrices(BaseModel):
neuralSearch_1_25_results: float
neuralSearch_26_100_results: float
neuralSearch_100_plus_results: float
keywordSearch_1_100_results: float
keywordSearch_100_plus_results: float
class PerPagePrices(BaseModel):
contentText: float
contentHighlight: float
contentSummary: float
class CostDollars(BaseModel):
total: float
breakDown: list[SearchBreakdown]
perRequestPrices: PerRequestPrices
perPagePrices: PerPagePrices
class ExaAnswerBlock(Block):
class Input(BlockSchema):
credentials: CredentialsMetaInput = exa.credentials_field(
@@ -21,11 +57,6 @@ class ExaAnswerBlock(Block):
description="The question or query to answer",
placeholder="What is the latest valuation of SpaceX?",
)
stream: bool = SchemaField(
default=False,
description="If true, the response is returned as a server-sent events (SSE) stream",
advanced=True,
)
text: bool = SchemaField(
default=False,
description="If true, the response includes full text content in the search results",
@@ -46,8 +77,8 @@ class ExaAnswerBlock(Block):
description="Search results used to generate the answer",
default_factory=list,
)
cost_dollars: dict = SchemaField(
description="Cost breakdown of the request", default_factory=dict
cost_dollars: CostDollars = SchemaField(
description="Cost breakdown of the request"
)
error: str = SchemaField(
description="Error message if the request failed", default=""
@@ -55,7 +86,7 @@ class ExaAnswerBlock(Block):
def __init__(self):
super().__init__(
id="f8e7d6c5-b4a3-5c2d-9e1f-3a7b8c9d4e6f",
id="b79ca4cc-9d5e-47d1-9d4f-e3a2d7f28df5",
description="Get an LLM answer to a question informed by Exa search results",
categories={BlockCategory.SEARCH, BlockCategory.AI},
input_schema=ExaAnswerBlock.Input,
@@ -74,14 +105,11 @@ class ExaAnswerBlock(Block):
# Build the payload
payload = {
"query": input_data.query,
"stream": input_data.stream,
"text": input_data.text,
"model": input_data.model,
}
try:
# Note: This endpoint doesn't support streaming in our block implementation
# If stream=True is requested, we still make a regular request
response = await Requests().post(url, headers=headers, json=payload)
data = response.json()

View File

@@ -54,23 +54,11 @@ class ExaContentsBlock(Block):
}
# Convert ContentSettings to API format
contents_dict = input_data.contents.model_dump()
payload = {
"ids": input_data.ids,
"text": {
"maxCharacters": contents_dict["text"]["max_characters"],
"includeHtmlTags": contents_dict["text"]["include_html_tags"],
},
"highlights": {
"numSentences": contents_dict["highlights"]["num_sentences"],
"highlightsPerUrl": contents_dict["highlights"]["highlights_per_url"],
"query": contents_dict["summary"][
"query"
], # Note: query comes from summary
},
"summary": {
"query": contents_dict["summary"]["query"],
},
"text": input_data.contents.text,
"highlights": input_data.contents.highlights,
"summary": input_data.contents.summary,
}
try:

View File

@@ -38,24 +38,14 @@ class SummarySettings(BaseModel):
class ContentSettings(BaseModel):
text: dict = SchemaField(
description="Text content settings",
default={"maxCharacters": 1000, "includeHtmlTags": False},
advanced=True,
text: TextSettings = SchemaField(
default=TextSettings(),
)
highlights: dict = SchemaField(
description="Highlight settings",
default={
"numSentences": 3,
"highlightsPerUrl": 3,
"query": "",
},
advanced=True,
highlights: HighlightSettings = SchemaField(
default=HighlightSettings(),
)
summary: dict = SchemaField(
description="Summary settings",
default={"query": ""},
advanced=True,
summary: SummarySettings = SchemaField(
default=SummarySettings(),
)

View File

@@ -68,7 +68,7 @@ class ExaSearchBlock(Block):
description="List of search results", default_factory=list
)
error: str = SchemaField(
description="Error message if the request failed", default=""
description="Error message if the request failed",
)
def __init__(self):

View File

@@ -114,7 +114,7 @@ class ExaWebsetWebhookBlock(Block):
def __init__(self):
super().__init__(
id="d1e2f3a4-b5c6-7d8e-9f0a-1b2c3d4e5f6a",
id="d0204ed8-8b81-408d-8b8d-ed087a546228",
description="Receive webhook notifications for Exa webset events",
categories={BlockCategory.INPUT},
input_schema=ExaWebsetWebhookBlock.Input,

View File

@@ -57,7 +57,7 @@ class ExaCreateWebsetBlock(Block):
def __init__(self):
super().__init__(
id="a7c3b1d4-9e2f-4c5a-8f1b-3e6d7a9c2b5e",
id="0cda29ff-c549-4a19-8805-c982b7d4ec34",
description="Create a new Exa Webset for persistent web search collections",
categories={BlockCategory.SEARCH},
input_schema=ExaCreateWebsetBlock.Input,
@@ -139,7 +139,7 @@ class ExaUpdateWebsetBlock(Block):
def __init__(self):
super().__init__(
id="c9e5d3f6-2a4b-6e7c-1f3d-5a8b9c4e7d2f",
id="89ccd99a-3c2b-4fbf-9e25-0ffa398d0314",
description="Update metadata for an existing Webset",
categories={BlockCategory.SEARCH},
input_schema=ExaUpdateWebsetBlock.Input,
@@ -211,7 +211,7 @@ class ExaListWebsetsBlock(Block):
def __init__(self):
super().__init__(
id="f3h8g6i9-5d7e-9b1f-4c6g-8d2f3h7i1a5c",
id="1dcd8fd6-c13f-4e6f-bd4c-654428fa4757",
description="List all Websets with pagination support",
categories={BlockCategory.SEARCH},
input_schema=ExaListWebsetsBlock.Input,
@@ -294,7 +294,7 @@ class ExaGetWebsetBlock(Block):
def __init__(self):
super().__init__(
id="b8d4c2e5-1f3a-5d6b-9e2c-4f7a8b3d6c9f",
id="6ab8e12a-132c-41bf-b5f3-d662620fa832",
description="Retrieve a Webset by ID or external ID",
categories={BlockCategory.SEARCH},
input_schema=ExaGetWebsetBlock.Input,
@@ -367,7 +367,7 @@ class ExaDeleteWebsetBlock(Block):
def __init__(self):
super().__init__(
id="d1f6e4g7-3b5c-7f8d-2a4e-6b9c1d5f8e3a",
id="aa6994a2-e986-421f-8d4c-7671d3be7b7e",
description="Delete a Webset and all its items",
categories={BlockCategory.SEARCH},
input_schema=ExaDeleteWebsetBlock.Input,
@@ -425,7 +425,7 @@ class ExaCancelWebsetBlock(Block):
def __init__(self):
super().__init__(
id="e2g7f5h8-4c6d-8a9e-3b5f-7c1d2e6g9f4b",
id="e40a6420-1db8-47bb-b00a-0e6aecd74176",
description="Cancel all operations being performed on a Webset",
categories={BlockCategory.SEARCH},
input_schema=ExaCancelWebsetBlock.Input,

View File

@@ -30,17 +30,21 @@ EXAMPLE_SERVICE_TEST_CREDENTIALS_INPUT = {
# Configure the example webhook provider
example_webhook = (
ProviderBuilder("examplewebhook")
.with_api_key("EXAMPLE_WEBHOOK_API_KEY", "Example Webhook API Key")
.with_base_cost(0, BlockCostType.RUN) # Webhooks typically don't have run costs
ProviderBuilder(name="examplewebhook")
.with_api_key(
env_var_name="EXAMPLE_WEBHOOK_API_KEY", title="Example Webhook API Key"
)
.with_base_cost(
amount=0, cost_type=BlockCostType.RUN
) # Webhooks typically don't have run costs
.build()
)
# Advanced provider configuration
advanced_service = (
ProviderBuilder("advanced-service")
.with_api_key("ADVANCED_API_KEY", "Advanced Service API Key")
.with_base_cost(2, BlockCostType.RUN)
ProviderBuilder(name="advanced-service")
.with_api_key(env_var_name="ADVANCED_API_KEY", title="Advanced Service API Key")
.with_base_cost(amount=2, cost_type=BlockCostType.RUN)
.build()
)
@@ -53,15 +57,27 @@ class CustomAPIProvider:
self.credentials = credentials
async def request(self, method: str, endpoint: str, **kwargs):
# Simulated API request
# Example of how to use Requests module:
# from backend.sdk import Requests
# response = await Requests().post(
# url="https://api.example.com" + endpoint,
# headers={
# "Content-Type": "application/json",
# "x-api-key": self.credentials.api_key.get_secret_value()
# },
# json=kwargs.get("data", {})
# )
# return response.json()
# Simulated API request for example
return {"status": "ok", "data": kwargs.get("data", {})}
# Configure provider with custom API client
custom_api = (
ProviderBuilder("custom-api")
.with_api_key("CUSTOM_API_KEY", "Custom API Key")
.with_api_client(lambda creds: CustomAPIProvider(creds))
.with_base_cost(3, BlockCostType.RUN)
ProviderBuilder(name="custom-api")
.with_api_key(env_var_name="CUSTOM_API_KEY", title="Custom API Key")
.with_api_client(factory=lambda creds: CustomAPIProvider(creds))
.with_base_cost(amount=3, cost_type=BlockCostType.RUN)
.build()
)

View File

@@ -8,6 +8,8 @@ This demonstrates more advanced provider configurations including:
4. Multiple provider configurations
"""
import logging
from backend.sdk import (
APIKeyCredentials,
Block,
@@ -20,6 +22,8 @@ from backend.sdk import (
from ._config import advanced_service, custom_api
logger = logging.getLogger(__name__)
class AdvancedProviderBlock(Block):
"""
@@ -47,7 +51,7 @@ class AdvancedProviderBlock(Block):
def __init__(self):
super().__init__(
id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
id="d0086843-4c6c-4b9a-a490-d0e7b4cb317e",
description="Advanced provider example with multiple auth types",
categories={BlockCategory.DEVELOPER_TOOLS},
input_schema=AdvancedProviderBlock.Input,
@@ -62,17 +66,25 @@ class AdvancedProviderBlock(Block):
**kwargs,
) -> BlockOutput:
try:
logger.debug(
"Starting AdvancedProviderBlock run with operation: %s",
input_data.operation,
)
# Use API key authentication
_ = (
credentials.api_key.get_secret_value()
) # Would be used in real implementation
logger.debug("Successfully authenticated with API key")
result = f"Performed {input_data.operation} with API key auth"
logger.debug("Operation completed successfully")
yield "result", result
yield "auth_type", "api_key"
yield "success", True
except Exception as e:
logger.error("Error in AdvancedProviderBlock: %s", str(e))
yield "result", f"Error: {str(e)}"
yield "auth_type", "error"
yield "success", False
@@ -102,7 +114,7 @@ class CustomAPIBlock(Block):
def __init__(self):
super().__init__(
id="f9e8d7c6-b5a4-3210-fedc-ba9876543210",
id="979ccdfd-db5a-4179-ad57-aeb277999d79",
description="Example using custom API client provider",
categories={BlockCategory.DEVELOPER_TOOLS},
input_schema=CustomAPIBlock.Input,
@@ -113,19 +125,26 @@ class CustomAPIBlock(Block):
self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs
) -> BlockOutput:
try:
logger.debug(
"Starting CustomAPIBlock run with endpoint: %s", input_data.endpoint
)
# Get API client from provider
api_client = custom_api.get_api(credentials)
logger.debug("Successfully obtained API client")
# Make API request
logger.debug("Making API request with payload: %s", input_data.payload)
response = await api_client.request(
method="POST",
endpoint=input_data.endpoint,
data=input_data.payload,
)
logger.debug("Received API response: %s", response)
yield "response", str(response)
yield "status", response.get("status", "unknown")
except Exception as e:
logger.error("Error in CustomAPIBlock: %s", str(e))
yield "response", f"Error: {str(e)}"
yield "status", "error"

View File

@@ -9,3 +9,117 @@ from backend.util.test import execute_block_test
@pytest.mark.parametrize("block", get_blocks().values(), ids=lambda b: b.name)
async def test_available_blocks(block: Type[Block]):
await execute_block_test(block())
@pytest.mark.parametrize("block", get_blocks().values(), ids=lambda b: b.name)
async def test_block_ids_valid(block: Type[Block]):
# add the tests here to check they are uuid4
import uuid
# Skip list for blocks with known invalid UUIDs
skip_blocks = {
"GetWeatherInformationBlock",
"CodeExecutionBlock",
"CountdownTimerBlock",
"TwitterGetListTweetsBlock",
"TwitterRemoveListMemberBlock",
"TwitterAddListMemberBlock",
"TwitterGetListMembersBlock",
"TwitterGetListMembershipsBlock",
"TwitterUnfollowListBlock",
"TwitterFollowListBlock",
"TwitterUnpinListBlock",
"TwitterPinListBlock",
"TwitterGetPinnedListsBlock",
"TwitterDeleteListBlock",
"TwitterUpdateListBlock",
"TwitterCreateListBlock",
"TwitterGetListBlock",
"TwitterGetOwnedListsBlock",
"TwitterGetSpacesBlock",
"TwitterGetSpaceByIdBlock",
"TwitterGetSpaceBuyersBlock",
"TwitterGetSpaceTweetsBlock",
"TwitterSearchSpacesBlock",
"TwitterGetUserMentionsBlock",
"TwitterGetHomeTimelineBlock",
"TwitterGetUserTweetsBlock",
"TwitterGetTweetBlock",
"TwitterGetTweetsBlock",
"TwitterGetQuoteTweetsBlock",
"TwitterLikeTweetBlock",
"TwitterGetLikingUsersBlock",
"TwitterGetLikedTweetsBlock",
"TwitterUnlikeTweetBlock",
"TwitterBookmarkTweetBlock",
"TwitterGetBookmarkedTweetsBlock",
"TwitterRemoveBookmarkTweetBlock",
"TwitterRetweetBlock",
"TwitterRemoveRetweetBlock",
"TwitterGetRetweetersBlock",
"TwitterHideReplyBlock",
"TwitterUnhideReplyBlock",
"TwitterPostTweetBlock",
"TwitterDeleteTweetBlock",
"TwitterSearchRecentTweetsBlock",
"TwitterUnfollowUserBlock",
"TwitterFollowUserBlock",
"TwitterGetFollowersBlock",
"TwitterGetFollowingBlock",
"TwitterUnmuteUserBlock",
"TwitterGetMutedUsersBlock",
"TwitterMuteUserBlock",
"TwitterGetBlockedUsersBlock",
"TwitterGetUserBlock",
"TwitterGetUsersBlock",
"TodoistCreateLabelBlock",
"TodoistListLabelsBlock",
"TodoistGetLabelBlock",
"TodoistUpdateLabelBlock",
"TodoistDeleteLabelBlock",
"TodoistGetSharedLabelsBlock",
"TodoistRenameSharedLabelsBlock",
"TodoistRemoveSharedLabelsBlock",
"TodoistCreateTaskBlock",
"TodoistGetTasksBlock",
"TodoistGetTaskBlock",
"TodoistUpdateTaskBlock",
"TodoistCloseTaskBlock",
"TodoistReopenTaskBlock",
"TodoistDeleteTaskBlock",
"TodoistListSectionsBlock",
"TodoistGetSectionBlock",
"TodoistDeleteSectionBlock",
"TodoistCreateProjectBlock",
"TodoistGetProjectBlock",
"TodoistUpdateProjectBlock",
"TodoistDeleteProjectBlock",
"TodoistListCollaboratorsBlock",
"TodoistGetCommentsBlock",
"TodoistGetCommentBlock",
"TodoistUpdateCommentBlock",
"TodoistDeleteCommentBlock",
"GithubListStargazersBlock",
"Slant3DSlicerBlock",
}
block_instance = block()
# Skip blocks with known invalid UUIDs
if block_instance.__class__.__name__ in skip_blocks:
pytest.skip(
f"Skipping UUID check for {block_instance.__class__.__name__} - known invalid UUID"
)
# Check that the ID is not empty
assert block_instance.id, f"Block {block.name} has empty ID"
# Check that the ID is a valid UUID4
try:
parsed_uuid = uuid.UUID(block_instance.id)
# Verify it's specifically UUID version 4
assert (
parsed_uuid.version == 4
), f"Block {block.name} ID is UUID version {parsed_uuid.version}, expected version 4"
except ValueError:
pytest.fail(f"Block {block.name} has invalid UUID format: {block_instance.id}")

View File

@@ -5,7 +5,7 @@ This module provides models that will be included in the OpenAPI schema generati
allowing frontend code generators like Orval to create corresponding TypeScript types.
"""
from typing import Dict, List, Literal
from typing import List, Literal
from pydantic import BaseModel, Field, create_model
@@ -83,7 +83,7 @@ class ProviderConstants(BaseModel):
This is designed to be converted by Orval into a TypeScript constant.
"""
PROVIDER_NAMES: Dict[str, str] = Field(
PROVIDER_NAMES: dict[str, str] = Field(
description="All available provider names as a constant mapping",
default_factory=lambda: {
name.upper().replace("-", "_"): name for name in get_all_provider_names()

View File

@@ -669,17 +669,14 @@ async def execute_graph(
)
async def stop_graph_run(
graph_id: str, graph_exec_id: str, user_id: Annotated[str, Depends(get_user_id)]
) -> execution_db.GraphExecutionMeta:
) -> execution_db.GraphExecutionMeta | None:
res = await _stop_graph_run(
user_id=user_id,
graph_id=graph_id,
graph_exec_id=graph_exec_id,
)
if not res:
raise HTTPException(
status_code=HTTP_404_NOT_FOUND,
detail=f"Graph execution #{graph_exec_id} not found.",
)
return None
return res[0]

View File

@@ -1,5 +1,6 @@
import json
from typing import Any, Type, TypeVar, cast, get_args, get_origin
import types
from typing import Any, Type, TypeVar, Union, cast, get_args, get_origin, overload
from prisma import Json as PrismaJson
@@ -104,9 +105,37 @@ def __convert_bool(value: Any) -> bool:
return bool(value)
def _try_convert(value: Any, target_type: Type, raise_on_mismatch: bool) -> Any:
def _try_convert(value: Any, target_type: Any, raise_on_mismatch: bool) -> Any:
origin = get_origin(target_type)
args = get_args(target_type)
# Handle Union types (including Optional which is Union[T, None])
if origin is Union or origin is types.UnionType:
# Handle None values for Optional types
if value is None:
if type(None) in args:
return None
elif raise_on_mismatch:
raise TypeError(f"Value {value} is not of expected type {target_type}")
else:
return value
# Try to convert to each type in the union, excluding None
non_none_types = [arg for arg in args if arg is not type(None)]
# Try each type in the union, using the original raise_on_mismatch behavior
for arg_type in non_none_types:
try:
return _try_convert(value, arg_type, raise_on_mismatch)
except (TypeError, ValueError, ConversionError):
continue
# If no conversion succeeded
if raise_on_mismatch:
raise TypeError(f"Value {value} is not of expected type {target_type}")
else:
return value
if origin is None:
origin = target_type
if origin not in [list, dict, tuple, str, set, int, float, bool]:
@@ -189,11 +218,19 @@ def type_match(value: Any, target_type: Type[T]) -> T:
return cast(T, _try_convert(value, target_type, raise_on_mismatch=True))
def convert(value: Any, target_type: Type[T]) -> T:
@overload
def convert(value: Any, target_type: Type[T]) -> T: ...
@overload
def convert(value: Any, target_type: Any) -> Any: ...
def convert(value: Any, target_type: Any) -> Any:
try:
if isinstance(value, PrismaJson):
value = value.data
return cast(T, _try_convert(value, target_type, raise_on_mismatch=False))
return _try_convert(value, target_type, raise_on_mismatch=False)
except Exception as e:
raise ConversionError(f"Failed to convert {value} to {target_type}") from e
@@ -203,6 +240,7 @@ class FormattedStringType(str):
@classmethod
def __get_pydantic_core_schema__(cls, source_type, handler):
_ = source_type # unused parameter required by pydantic
return handler(str)
@classmethod

View File

@@ -1,3 +1,5 @@
from typing import List, Optional
from backend.util.type import convert
@@ -5,6 +7,8 @@ def test_type_conversion():
assert convert(5.5, int) == 5
assert convert("5.5", int) == 5
assert convert([1, 2, 3], int) == 3
assert convert("7", Optional[int]) == 7
assert convert("7", int | None) == 7
assert convert("5.5", float) == 5.5
assert convert(5, float) == 5.0
@@ -25,8 +29,6 @@ def test_type_conversion():
assert convert([1, 2, 3], dict) == {0: 1, 1: 2, 2: 3}
assert convert((1, 2, 3), dict) == {0: 1, 1: 2, 2: 3}
from typing import List
assert convert("5", List[int]) == [5]
assert convert("[5,4,2]", List[int]) == [5, 4, 2]
assert convert([5, 4, 2], List[str]) == ["5", "4", "2"]

View File

@@ -5,7 +5,7 @@ This test suite verifies that blocks can be created using only SDK imports
and that they work correctly without decorators.
"""
from typing import Optional
from typing import Any, Optional, Union
import pytest
@@ -13,9 +13,12 @@ from backend.sdk import (
APIKeyCredentials,
Block,
BlockCategory,
BlockCostType,
BlockOutput,
BlockSchema,
CredentialsMetaInput,
OAuth2Credentials,
ProviderBuilder,
SchemaField,
SecretStr,
)
@@ -434,5 +437,478 @@ class TestComplexBlockScenarios:
pass
class TestAuthenticationVariants:
"""Test complex authentication scenarios including OAuth, API keys, and scopes."""
@pytest.mark.asyncio
async def test_oauth_block_with_scopes(self):
"""Test creating a block that uses OAuth2 with scopes."""
from backend.sdk import OAuth2Credentials, ProviderBuilder
# Create a test OAuth provider with scopes
# For testing, we don't need an actual OAuth handler
# In real usage, you would provide a proper OAuth handler class
oauth_provider = (
ProviderBuilder("test_oauth_provider")
.with_api_key("TEST_OAUTH_API", "Test OAuth API")
.with_base_cost(5, BlockCostType.RUN)
.build()
)
class OAuthScopedBlock(Block):
"""Block requiring OAuth2 with specific scopes."""
class Input(BlockSchema):
credentials: CredentialsMetaInput = oauth_provider.credentials_field(
description="OAuth2 credentials with scopes",
scopes=["read:user", "write:data"],
)
resource: str = SchemaField(description="Resource to access")
class Output(BlockSchema):
data: str = SchemaField(description="Retrieved data")
scopes_used: list[str] = SchemaField(
description="Scopes that were used"
)
token_info: dict[str, Any] = SchemaField(
description="Token information"
)
def __init__(self):
super().__init__(
id="oauth-scoped-block",
description="Test OAuth2 with scopes",
categories={BlockCategory.DEVELOPER_TOOLS},
input_schema=OAuthScopedBlock.Input,
output_schema=OAuthScopedBlock.Output,
)
async def run(
self, input_data: Input, *, credentials: OAuth2Credentials, **kwargs
) -> BlockOutput:
# Simulate OAuth API call with scopes
token = credentials.access_token.get_secret_value()
yield "data", f"OAuth data for {input_data.resource}"
yield "scopes_used", credentials.scopes or []
yield "token_info", {
"has_token": bool(token),
"has_refresh": credentials.refresh_token is not None,
"provider": credentials.provider,
"expires_at": credentials.access_token_expires_at,
}
# Create test OAuth credentials
test_oauth_creds = OAuth2Credentials(
id="test-oauth-creds",
provider="test_oauth_provider",
access_token=SecretStr("test-access-token"),
refresh_token=SecretStr("test-refresh-token"),
scopes=["read:user", "write:data"],
title="Test OAuth Credentials",
)
# Test the block
block = OAuthScopedBlock()
outputs = {}
async for name, value in block.run(
OAuthScopedBlock.Input(
credentials={ # type: ignore
"provider": "test_oauth_provider",
"id": "test-oauth-creds",
"type": "oauth2",
},
resource="user/profile",
),
credentials=test_oauth_creds,
):
outputs[name] = value
assert outputs["data"] == "OAuth data for user/profile"
assert set(outputs["scopes_used"]) == {"read:user", "write:data"}
assert outputs["token_info"]["has_token"] is True
assert outputs["token_info"]["expires_at"] is None
assert outputs["token_info"]["has_refresh"] is True
@pytest.mark.asyncio
async def test_mixed_auth_block(self):
"""Test block that supports both OAuth2 and API key authentication."""
# No need to import these again, already imported at top
# Create provider supporting both auth types
# Create provider supporting API key auth
# In real usage, you would add OAuth support with .with_oauth()
mixed_provider = (
ProviderBuilder("mixed_auth_provider")
.with_api_key("MIXED_API_KEY", "Mixed Provider API Key")
.with_base_cost(8, BlockCostType.RUN)
.build()
)
class MixedAuthBlock(Block):
"""Block supporting multiple authentication methods."""
class Input(BlockSchema):
credentials: CredentialsMetaInput = mixed_provider.credentials_field(
description="API key or OAuth2 credentials",
supported_credential_types=["api_key", "oauth2"],
)
operation: str = SchemaField(description="Operation to perform")
class Output(BlockSchema):
result: str = SchemaField(description="Operation result")
auth_type: str = SchemaField(description="Authentication type used")
auth_details: dict[str, Any] = SchemaField(description="Auth details")
def __init__(self):
super().__init__(
id="mixed-auth-block",
description="Block supporting OAuth2 and API key",
categories={BlockCategory.DEVELOPER_TOOLS},
input_schema=MixedAuthBlock.Input,
output_schema=MixedAuthBlock.Output,
)
async def run(
self,
input_data: Input,
*,
credentials: Union[APIKeyCredentials, OAuth2Credentials],
**kwargs,
) -> BlockOutput:
# Handle different credential types
if isinstance(credentials, APIKeyCredentials):
auth_type = "api_key"
auth_details = {
"has_key": bool(credentials.api_key.get_secret_value()),
"key_prefix": credentials.api_key.get_secret_value()[:5]
+ "...",
}
elif isinstance(credentials, OAuth2Credentials):
auth_type = "oauth2"
auth_details = {
"has_token": bool(credentials.access_token.get_secret_value()),
"scopes": credentials.scopes or [],
}
else:
auth_type = "unknown"
auth_details = {}
yield "result", f"Performed {input_data.operation} with {auth_type}"
yield "auth_type", auth_type
yield "auth_details", auth_details
# Test with API key
api_creds = APIKeyCredentials(
id="mixed-api-creds",
provider="mixed_auth_provider",
api_key=SecretStr("sk-1234567890"),
title="Mixed API Key",
)
block = MixedAuthBlock()
outputs = {}
async for name, value in block.run(
MixedAuthBlock.Input(
credentials={ # type: ignore
"provider": "mixed_auth_provider",
"id": "mixed-api-creds",
"type": "api_key",
},
operation="fetch_data",
),
credentials=api_creds,
):
outputs[name] = value
assert outputs["auth_type"] == "api_key"
assert outputs["result"] == "Performed fetch_data with api_key"
assert outputs["auth_details"]["key_prefix"] == "sk-12..."
# Test with OAuth2
oauth_creds = OAuth2Credentials(
id="mixed-oauth-creds",
provider="mixed_auth_provider",
access_token=SecretStr("oauth-token-123"),
scopes=["full_access"],
title="Mixed OAuth",
)
outputs = {}
async for name, value in block.run(
MixedAuthBlock.Input(
credentials={ # type: ignore
"provider": "mixed_auth_provider",
"id": "mixed-oauth-creds",
"type": "oauth2",
},
operation="update_data",
),
credentials=oauth_creds,
):
outputs[name] = value
assert outputs["auth_type"] == "oauth2"
assert outputs["result"] == "Performed update_data with oauth2"
assert outputs["auth_details"]["scopes"] == ["full_access"]
@pytest.mark.asyncio
async def test_multiple_credentials_block(self):
"""Test block requiring multiple different credentials."""
from backend.sdk import ProviderBuilder
# Create multiple providers
primary_provider = (
ProviderBuilder("primary_service")
.with_api_key("PRIMARY_API_KEY", "Primary Service Key")
.build()
)
# For testing purposes, using API key instead of OAuth handler
secondary_provider = (
ProviderBuilder("secondary_service")
.with_api_key("SECONDARY_API_KEY", "Secondary Service Key")
.build()
)
class MultiCredentialBlock(Block):
"""Block requiring credentials from multiple services."""
class Input(BlockSchema):
primary_credentials: CredentialsMetaInput = (
primary_provider.credentials_field(
description="Primary service API key"
)
)
secondary_credentials: CredentialsMetaInput = (
secondary_provider.credentials_field(
description="Secondary service OAuth"
)
)
merge_data: bool = SchemaField(
description="Whether to merge data from both services",
default=True,
)
class Output(BlockSchema):
primary_data: str = SchemaField(description="Data from primary service")
secondary_data: str = SchemaField(
description="Data from secondary service"
)
merged_result: Optional[str] = SchemaField(
description="Merged data if requested"
)
def __init__(self):
super().__init__(
id="multi-credential-block",
description="Block using multiple credentials",
categories={BlockCategory.DEVELOPER_TOOLS},
input_schema=MultiCredentialBlock.Input,
output_schema=MultiCredentialBlock.Output,
)
async def run(
self,
input_data: Input,
*,
primary_credentials: APIKeyCredentials,
secondary_credentials: OAuth2Credentials,
**kwargs,
) -> BlockOutput:
# Simulate fetching data with primary API key
primary_data = f"Primary data using {primary_credentials.provider}"
yield "primary_data", primary_data
# Simulate fetching data with secondary OAuth
secondary_data = f"Secondary data with {len(secondary_credentials.scopes or [])} scopes"
yield "secondary_data", secondary_data
# Merge if requested
if input_data.merge_data:
merged = f"{primary_data} + {secondary_data}"
yield "merged_result", merged
else:
yield "merged_result", None
# Create test credentials
primary_creds = APIKeyCredentials(
id="primary-creds",
provider="primary_service",
api_key=SecretStr("primary-key-123"),
title="Primary Key",
)
secondary_creds = OAuth2Credentials(
id="secondary-creds",
provider="secondary_service",
access_token=SecretStr("secondary-token"),
scopes=["read", "write"],
title="Secondary OAuth",
)
# Test the block
block = MultiCredentialBlock()
outputs = {}
# Note: In real usage, the framework would inject the correct credentials
# based on the field names. Here we simulate that behavior.
async for name, value in block.run(
MultiCredentialBlock.Input(
primary_credentials={ # type: ignore
"provider": "primary_service",
"id": "primary-creds",
"type": "api_key",
},
secondary_credentials={ # type: ignore
"provider": "secondary_service",
"id": "secondary-creds",
"type": "oauth2",
},
merge_data=True,
),
primary_credentials=primary_creds,
secondary_credentials=secondary_creds,
):
outputs[name] = value
assert outputs["primary_data"] == "Primary data using primary_service"
assert outputs["secondary_data"] == "Secondary data with 2 scopes"
assert "Primary data" in outputs["merged_result"]
assert "Secondary data" in outputs["merged_result"]
@pytest.mark.asyncio
async def test_oauth_scope_validation(self):
"""Test OAuth scope validation and handling."""
from backend.sdk import OAuth2Credentials, ProviderBuilder
# Provider with specific required scopes
# For testing OAuth scope validation
scoped_provider = (
ProviderBuilder("scoped_oauth_service")
.with_api_key("SCOPED_OAUTH_KEY", "Scoped OAuth Service")
.build()
)
class ScopeValidationBlock(Block):
"""Block that validates OAuth scopes."""
class Input(BlockSchema):
credentials: CredentialsMetaInput = scoped_provider.credentials_field(
description="OAuth credentials with specific scopes",
scopes=["user:read", "user:write"], # Required scopes
)
require_admin: bool = SchemaField(
description="Whether admin scopes are required",
default=False,
)
class Output(BlockSchema):
allowed_operations: list[str] = SchemaField(
description="Operations allowed with current scopes"
)
missing_scopes: list[str] = SchemaField(
description="Scopes that are missing for full access"
)
has_required_scopes: bool = SchemaField(
description="Whether all required scopes are present"
)
def __init__(self):
super().__init__(
id="scope-validation-block",
description="Block that validates OAuth scopes",
categories={BlockCategory.DEVELOPER_TOOLS},
input_schema=ScopeValidationBlock.Input,
output_schema=ScopeValidationBlock.Output,
)
async def run(
self, input_data: Input, *, credentials: OAuth2Credentials, **kwargs
) -> BlockOutput:
current_scopes = set(credentials.scopes or [])
required_scopes = {"user:read", "user:write"}
if input_data.require_admin:
required_scopes.update({"admin:read", "admin:write"})
# Determine allowed operations based on scopes
allowed_ops = []
if "user:read" in current_scopes:
allowed_ops.append("read_user_data")
if "user:write" in current_scopes:
allowed_ops.append("update_user_data")
if "admin:read" in current_scopes:
allowed_ops.append("read_admin_data")
if "admin:write" in current_scopes:
allowed_ops.append("update_admin_data")
missing = list(required_scopes - current_scopes)
has_required = len(missing) == 0
yield "allowed_operations", allowed_ops
yield "missing_scopes", missing
yield "has_required_scopes", has_required
# Test with partial scopes
partial_creds = OAuth2Credentials(
id="partial-oauth",
provider="scoped_oauth_service",
access_token=SecretStr("partial-token"),
scopes=["user:read"], # Only one of the required scopes
title="Partial OAuth",
)
block = ScopeValidationBlock()
outputs = {}
async for name, value in block.run(
ScopeValidationBlock.Input(
credentials={ # type: ignore
"provider": "scoped_oauth_service",
"id": "partial-oauth",
"type": "oauth2",
},
require_admin=False,
),
credentials=partial_creds,
):
outputs[name] = value
assert outputs["allowed_operations"] == ["read_user_data"]
assert "user:write" in outputs["missing_scopes"]
assert outputs["has_required_scopes"] is False
# Test with all required scopes
full_creds = OAuth2Credentials(
id="full-oauth",
provider="scoped_oauth_service",
access_token=SecretStr("full-token"),
scopes=["user:read", "user:write", "admin:read"],
title="Full OAuth",
)
outputs = {}
async for name, value in block.run(
ScopeValidationBlock.Input(
credentials={ # type: ignore
"provider": "scoped_oauth_service",
"id": "full-oauth",
"type": "oauth2",
},
require_admin=False,
),
credentials=full_creds,
):
outputs[name] = value
assert set(outputs["allowed_operations"]) == {
"read_user_data",
"update_user_data",
"read_admin_data",
}
assert outputs["missing_scopes"] == []
assert outputs["has_required_scopes"] is True
if __name__ == "__main__":
pytest.main([__file__, "-v"])

View File

@@ -121,6 +121,7 @@
"msw": "2.10.2",
"msw-storybook-addon": "2.0.5",
"orval": "7.10.0",
"pbkdf2": "3.1.3",
"postcss": "8.5.6",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.6.13",

View File

@@ -13,6 +13,8 @@ dotenv.config({ path: path.resolve(__dirname, "../backend/.env") });
*/
export default defineConfig({
testDir: "./src/tests",
/* Global setup file that runs before all tests */
globalSetup: "./src/tests/global-setup.ts",
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */

View File

@@ -288,6 +288,9 @@ importers:
orval:
specifier: 7.10.0
version: 7.10.0(openapi-types@12.1.3)
pbkdf2:
specifier: 3.1.3
version: 3.1.3
postcss:
specifier: 8.5.6
version: 8.5.6
@@ -3812,11 +3815,6 @@ packages:
create-hmac@1.1.7:
resolution: {integrity: sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==}
create-jest@29.7.0:
resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
hasBin: true
cross-env@7.0.3:
resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
@@ -11175,7 +11173,7 @@ snapshots:
dependencies:
cipher-base: 1.0.6
inherits: 2.0.4
ripemd160: 2.0.1
ripemd160: 2.0.2
sha.js: 2.4.11
create-hash@1.2.0:
@@ -11195,21 +11193,6 @@ snapshots:
safe-buffer: 5.2.1
sha.js: 2.4.11
create-jest@29.7.0(@types/node@22.15.30):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.11
jest-config: 29.7.0(@types/node@22.15.30)
jest-util: 29.7.0
prompts: 2.4.2
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
- supports-color
- ts-node
cross-env@7.0.3:
dependencies:
cross-spawn: 7.0.6

View File

@@ -1,23 +1,23 @@
"use client";
import SmartImage from "@/components/agptui/SmartImage";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
import OnboardingButton from "@/components/onboarding/OnboardingButton";
import {
OnboardingStep,
OnboardingHeader,
OnboardingStep,
} from "@/components/onboarding/OnboardingStep";
import { OnboardingText } from "@/components/onboarding/OnboardingText";
import StarRating from "@/components/onboarding/StarRating";
import { Play } from "lucide-react";
import { cn } from "@/lib/utils";
import { useCallback, useEffect, useState } from "react";
import { GraphMeta, StoreAgentDetails } from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { useRouter } from "next/navigation";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import SchemaTooltip from "@/components/SchemaTooltip";
import { TypeBasedInput } from "@/components/type-based-input";
import SmartImage from "@/components/agptui/SmartImage";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useToast } from "@/components/ui/use-toast";
import { GraphMeta, StoreAgentDetails } from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { cn } from "@/lib/utils";
import { Play } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
export default function Page() {
const { state, updateState, setStep } = useOnboarding(
@@ -52,7 +52,7 @@ export default function Page() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const update: { [key: string]: any } = {};
// Set default values from schema
Object.entries(agent.input_schema.properties).forEach(
Object.entries(agent.input_schema?.properties || {}).forEach(
([key, value]) => {
// Skip if already set
if (state.agentInput && state.agentInput[key]) {
@@ -224,7 +224,7 @@ export default function Page() {
<CardTitle className="font-poppins text-lg">Input</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{Object.entries(agent?.input_schema.properties || {}).map(
{Object.entries(agent?.input_schema?.properties || {}).map(
([key, inputSubSchema]) => (
<div key={key} className="flex flex-col space-y-2">
<label className="flex items-center gap-1 text-sm font-medium">

View File

@@ -1,4 +1,5 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import React, {
useCallback,
useEffect,
@@ -6,31 +7,31 @@ import React, {
useRef,
useState,
} from "react";
import { useParams, useRouter } from "next/navigation";
import { exportAsJSONFile } from "@/lib/utils";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import {
Graph,
GraphExecution,
GraphExecutionID,
GraphExecutionMeta,
Graph,
GraphID,
LibraryAgent,
LibraryAgentID,
Schedule,
ScheduleID,
LibraryAgentPreset,
LibraryAgentPresetID,
Schedule,
ScheduleID,
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { exportAsJSONFile } from "@/lib/utils";
import type { ButtonAction } from "@/components/agptui/types";
import DeleteConfirmDialog from "@/components/agptui/delete-confirm-dialog";
import AgentRunDraftView from "@/components/agents/agent-run-draft-view";
import AgentRunDetailsView from "@/components/agents/agent-run-details-view";
import AgentRunDraftView from "@/components/agents/agent-run-draft-view";
import AgentRunsSelectorList from "@/components/agents/agent-runs-selector-list";
import AgentScheduleDetailsView from "@/components/agents/agent-schedule-details-view";
import DeleteConfirmDialog from "@/components/agptui/delete-confirm-dialog";
import type { ButtonAction } from "@/components/agptui/types";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -39,9 +40,8 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
import LoadingBox, { LoadingSpinner } from "@/components/ui/loading";
import { useToast } from "@/components/ui/use-toast";
export default function AgentRunsPage(): React.ReactElement {
const { id: agentID }: { id: LibraryAgentID } = useParams();
@@ -434,6 +434,9 @@ export default function AgentRunsPage(): React.ReactElement {
[agent, downloadGraph],
);
const runGraph =
graphVersions.current[selectedRun?.graph_version ?? 0] ?? graph;
const onCreateSchedule = useCallback(
(schedule: Schedule) => {
setSchedules((prev) => [...prev, schedule]);
@@ -496,16 +499,16 @@ export default function AgentRunsPage(): React.ReactElement {
{/* Run / Schedule views */}
{(selectedView.type == "run" && selectedView.id ? (
selectedRun && (
selectedRun && runGraph ? (
<AgentRunDetailsView
agent={agent}
graph={graphVersions.current[selectedRun.graph_version] ?? graph}
graph={runGraph}
run={selectedRun}
agentActions={agentActions}
onRun={selectRun}
deleteRun={() => setConfirmingDeleteAgentRun(selectedRun)}
/>
)
) : null
) : selectedView.type == "run" ? (
/* Draft new runs / Create new presets */
<AgentRunDraftView
@@ -529,7 +532,8 @@ export default function AgentRunsPage(): React.ReactElement {
agentActions={agentActions}
/>
) : selectedView.type == "schedule" ? (
selectedSchedule && (
selectedSchedule &&
graph && (
<AgentScheduleDetailsView
graph={graph}
schedule={selectedSchedule}

View File

@@ -45,6 +45,8 @@ import type { HTTPValidationError } from "../../models/hTTPValidationError";
import type { PostV1ExecuteGraphAgentParams } from "../../models/postV1ExecuteGraphAgentParams";
import type { PostV1StopGraphExecution200 } from "../../models/postV1StopGraphExecution200";
import type { PostV1StopGraphExecutionsParams } from "../../models/postV1StopGraphExecutionsParams";
import type { SetGraphActiveVersion } from "../../models/setGraphActiveVersion";
@@ -1498,7 +1500,7 @@ export const usePostV1ExecuteGraphAgent = <
* @summary Stop graph execution
*/
export type postV1StopGraphExecutionResponse200 = {
data: GraphExecutionMeta;
data: PostV1StopGraphExecution200;
status: 200;
};

View File

@@ -5,8 +5,10 @@
* This server is used to execute agents that are created by the AutoGPT system.
* OpenAPI spec version: 0.1
*/
import type { LibraryAgentCredentialsInputSchemaAnyOf } from "./libraryAgentCredentialsInputSchemaAnyOf";
/**
* Input schema for credentials required by the agent
*/
export type LibraryAgentCredentialsInputSchema = { [key: string]: unknown };
export type LibraryAgentCredentialsInputSchema =
LibraryAgentCredentialsInputSchemaAnyOf | null;

View File

@@ -0,0 +1,11 @@
/**
* Generated by orval v7.10.0 🍺
* Do not edit manually.
* AutoGPT Agent Server
* This server is used to execute agents that are created by the AutoGPT system.
* OpenAPI spec version: 0.1
*/
export type LibraryAgentCredentialsInputSchemaAnyOf = {
[key: string]: unknown;
};

View File

@@ -0,0 +1,10 @@
/**
* Generated by orval v7.10.0 🍺
* Do not edit manually.
* AutoGPT Agent Server
* This server is used to execute agents that are created by the AutoGPT system.
* OpenAPI spec version: 0.1
*/
import type { GraphExecutionMeta } from "./graphExecutionMeta";
export type PostV1StopGraphExecution200 = GraphExecutionMeta | null;

View File

@@ -1480,7 +1480,13 @@
"description": "Successful Response",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/GraphExecutionMeta" }
"schema": {
"anyOf": [
{ "$ref": "#/components/schemas/GraphExecutionMeta" },
{ "type": "null" }
],
"title": "Response Postv1Stop Graph Execution"
}
}
}
},
@@ -4667,8 +4673,10 @@
"title": "Input Schema"
},
"credentials_input_schema": {
"additionalProperties": true,
"type": "object",
"anyOf": [
{ "additionalProperties": true, "type": "object" },
{ "type": "null" }
],
"title": "Credentials Input Schema",
"description": "Input schema for credentials required by the agent"
},

View File

@@ -86,7 +86,7 @@ function createUnsupportedContentTypeResponse(
"application/x-www-form-urlencoded",
],
},
{ status: 415 }, // Unsupported Media Type
{ status: 415 },
);
}
@@ -110,9 +110,19 @@ function createErrorResponse(error: unknown): NextResponse {
// If it's our custom ApiError, preserve the original status and response
if (error instanceof ApiError) {
return NextResponse.json(error.response || { error: error.message }, {
status: error.status,
});
}
// For JSON parsing errors, provide more context
if (error instanceof SyntaxError && error.message.includes("JSON")) {
return NextResponse.json(
error.response || { error: error.message, detail: error.message },
{ status: error.status },
{
error: "Invalid response from backend",
detail: error.message ?? "Backend returned non-JSON response",
},
{ status: 502 },
);
}
@@ -121,7 +131,7 @@ function createErrorResponse(error: unknown): NextResponse {
error instanceof Error ? error.message : "An unknown error occurred";
return NextResponse.json(
{ error: "Proxy request failed", detail },
{ status: 500 }, // Internal Server Error
{ status: 500 },
);
}

View File

@@ -1,9 +1,8 @@
"use client";
import React, { useCallback, useMemo } from "react";
import { isEmpty } from "lodash";
import moment from "moment";
import React, { useCallback, useMemo } from "react";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import {
Graph,
GraphExecution,
@@ -11,14 +10,15 @@ import {
GraphExecutionMeta,
LibraryAgent,
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import ActionButtonGroup from "@/components/agptui/action-button-group";
import type { ButtonAction } from "@/components/agptui/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { IconRefresh, IconSquare } from "@/components/ui/icons";
import { useToastOnFail } from "@/components/ui/use-toast";
import ActionButtonGroup from "@/components/agptui/action-button-group";
import LoadingBox from "@/components/ui/loading";
import { Input } from "@/components/ui/input";
import LoadingBox from "@/components/ui/loading";
import { useToastOnFail } from "@/components/ui/use-toast";
import {
AgentRunStatus,
@@ -199,7 +199,7 @@ export default function AgentRunDetailsView({
stopRun,
deleteRun,
graph.has_webhook_trigger,
graph.credentials_input_schema.properties,
graph.credentials_input_schema?.properties,
agent.can_access_graph,
run.graph_id,
run.graph_version,
@@ -242,7 +242,7 @@ export default function AgentRunDetailsView({
</label>
{values.map((value, i) => (
<p
className="resize-none whitespace-pre-wrap break-words border-none text-sm text-neutral-700 disabled:cursor-not-allowed"
className="resize-none overflow-x-auto whitespace-pre-wrap break-words border-none text-sm text-neutral-700 disabled:cursor-not-allowed"
key={i}
>
{value}

View File

@@ -1,7 +1,6 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import {
CredentialsMetaInput,
GraphExecutionID,
@@ -11,21 +10,21 @@ import {
LibraryAgentPresetUpdatable,
Schedule,
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import ActionButtonGroup from "@/components/agptui/action-button-group";
import type { ButtonAction } from "@/components/agptui/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { IconCross, IconPlay, IconSave } from "@/components/ui/icons";
import { CalendarClockIcon, Trash2Icon } from "lucide-react";
import { CronSchedulerDialog } from "@/components/cron-scheduler-dialog";
import { CredentialsInput } from "@/components/integrations/credentials-input";
import { TypeBasedInput } from "@/components/type-based-input";
import { useToastOnFail } from "@/components/ui/use-toast";
import ActionButtonGroup from "@/components/agptui/action-button-group";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
import SchemaTooltip from "@/components/SchemaTooltip";
import { useToast } from "@/components/ui/use-toast";
import { isEmpty } from "lodash";
import { TypeBasedInput } from "@/components/type-based-input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { IconCross, IconPlay, IconSave } from "@/components/ui/icons";
import { Input } from "@/components/ui/input";
import { useToast, useToastOnFail } from "@/components/ui/use-toast";
import { isEmpty } from "lodash";
import { CalendarClockIcon, Trash2Icon } from "lucide-react";
export default function AgentRunDraftView({
agent,
@@ -91,14 +90,14 @@ export default function AgentRunDraftView({
const agentInputFields = useMemo(
() =>
Object.fromEntries(
Object.entries(agentInputSchema.properties).filter(
Object.entries(agentInputSchema?.properties || {}).filter(
([_, subSchema]) => !subSchema.hidden,
),
),
[agentInputSchema],
);
const agentCredentialsInputFields = useMemo(
() => agent.credentials_input_schema.properties,
() => agent.credentials_input_schema?.properties || {},
[agent],
);

View File

@@ -9,16 +9,16 @@ import {
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { AgentRunStatus } from "@/components/agents/agent-run-status-chip";
import ActionButtonGroup from "@/components/agptui/action-button-group";
import type { ButtonAction } from "@/components/agptui/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
import { AgentRunStatus } from "@/components/agents/agent-run-status-chip";
import { useToastOnFail } from "@/components/ui/use-toast";
import ActionButtonGroup from "@/components/agptui/action-button-group";
import { IconCross } from "@/components/ui/icons";
import { PlayIcon } from "lucide-react";
import LoadingBox from "@/components/ui/loading";
import { Input } from "@/components/ui/input";
import LoadingBox from "@/components/ui/loading";
import { useToastOnFail } from "@/components/ui/use-toast";
import { humanizeCronExpression } from "@/lib/cron-expression-utils";
import { PlayIcon } from "lucide-react";
export default function AgentScheduleDetailsView({
graph,

View File

@@ -11,34 +11,7 @@ import {
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { useToastOnFail } from "@/components/ui/use-toast";
// --8<-- [start:CredentialsProviderNames]
// Helper function to convert provider names to display names
function toDisplayName(provider: string): string {
// Special cases that need manual handling
const specialCases: Record<string, string> = {
aiml_api: "AI/ML",
d_id: "D-ID",
e2b: "E2B",
llama_api: "Llama API",
open_router: "Open Router",
smtp: "SMTP",
revid: "Rev.ID",
};
if (specialCases[provider]) {
return specialCases[provider];
}
// General case: convert snake_case to Title Case
return provider
.split(/[_-]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
// Provider display names are now generated dynamically by toDisplayName function
// --8<-- [end:CredentialsProviderNames]
import { toDisplayName } from "@/components/integrations/helper";
type APIKeyCredentialsCreatable = Omit<
APIKeyCredentials,

View File

@@ -0,0 +1,27 @@
// --8<-- [start:CredentialsProviderNames]
// Helper function to convert provider names to display names
export function toDisplayName(provider: string): string {
// Special cases that need manual handling
const specialCases: Record<string, string> = {
aiml_api: "AI/ML",
d_id: "D-ID",
e2b: "E2B",
llama_api: "Llama API",
open_router: "Open Router",
smtp: "SMTP",
revid: "Rev.ID",
};
if (specialCases[provider]) {
return specialCases[provider];
}
// General case: convert snake_case to Title Case
return provider
.split(/[_-]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(" ");
}
// Provider display names are now generated dynamically by toDisplayName function
// --8<-- [end:CredentialsProviderNames]

View File

@@ -197,9 +197,10 @@ function useToastOnFail() {
return React.useCallback(
(action: string, { rethrow = false }: ToastOnFailOptions = {}) =>
(error: any) => {
const err = error as Error;
toast({
title: `Unable to ${action}`,
description: (error as Error)?.message ?? "Something went wrong",
description: err.message ?? "Something went wrong",
variant: "destructive",
duration: 10000,
});
@@ -211,4 +212,4 @@ function useToastOnFail() {
);
}
export { useToast, toast, useToastOnFail };
export { toast, useToast, useToastOnFail };

View File

@@ -78,7 +78,7 @@ export async function getServerAuthToken(): Promise<string> {
error,
} = await supabase.auth.getSession();
if (error || !session?.access_token) {
if (error || !session || !session.access_token) {
return "no-token-found";
}

View File

@@ -42,7 +42,7 @@ test.describe("Build", () => { //(1)!
});
// --8<-- [end:BuildPageExample]
test("user can add all blocks a-l", async ({ page }, testInfo) => {
test.skip("user can add all blocks a-l", async ({ page }, testInfo) => {
// this test is slow af so we 10x the timeout (sorry future me)
await test.setTimeout(testInfo.timeout * 100);
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy();
@@ -82,7 +82,7 @@ test.describe("Build", () => { //(1)!
await test.expect(page).toHaveURL(new RegExp("/.*build\\?flowID=.+"));
});
test("user can add all blocks m-z", async ({ page }, testInfo) => {
test.skip("user can add all blocks m-z", async ({ page }, testInfo) => {
// this test is slow af so we 10x the timeout (sorry future me)
await test.setTimeout(testInfo.timeout * 100);
await test.expect(buildPage.isLoaded()).resolves.toBeTruthy();

View File

@@ -0,0 +1,43 @@
import { FullConfig } from "@playwright/test";
import { createTestUsers, saveUserPool, loadUserPool } from "./utils/auth";
/**
* Global setup function that runs before all tests
* Creates test users and saves them to file system
*/
async function globalSetup(config: FullConfig) {
console.log("🚀 Starting global test setup...");
try {
const existingUserPool = await loadUserPool();
if (existingUserPool && existingUserPool.users.length > 0) {
console.log(
`♻️ Found existing user pool with ${existingUserPool.users.length} users`,
);
console.log("✅ Using existing user pool");
return;
}
// Create test users using signup page
const numberOfUsers = (config.workers || 1) + 3; // workers + buffer
console.log(`👥 Creating ${numberOfUsers} test users via signup...`);
const users = await createTestUsers(numberOfUsers);
if (users.length === 0) {
throw new Error("Failed to create any test users");
}
// Save user pool
await saveUserPool(users);
console.log("✅ Global setup completed successfully!");
console.log(`📊 Created ${users.length} test users via signup page`);
} catch (error) {
console.error("❌ Global setup failed:", error);
throw error;
}
}
export default globalSetup;

View File

@@ -0,0 +1,113 @@
import { test, expect } from "./fixtures";
import {
signupTestUser,
validateSignupForm,
generateTestEmail,
generateTestPassword,
} from "./utils/signup";
test.describe("Signup Flow", () => {
test("user can signup successfully", async ({ page }) => {
console.log("🧪 Testing user signup flow...");
try {
const testUser = await signupTestUser(page);
// Verify user was created
expect(testUser.email).toBeTruthy();
expect(testUser.password).toBeTruthy();
expect(testUser.createdAt).toBeTruthy();
// Verify we're on marketplace and authenticated
await expect(page).toHaveURL("/marketplace");
await expect(
page.getByText(
"Bringing you AI agents designed by thinkers from around the world",
),
).toBeVisible();
await expect(
page.getByTestId("profile-popout-menu-trigger"),
).toBeVisible();
console.log(`✅ User successfully signed up: ${testUser.email}`);
} catch (error) {
console.error("❌ Signup test failed:", error);
}
});
test("signup form validation works", async ({ page }) => {
console.log("🧪 Testing signup form validation...");
await validateSignupForm(page);
// Additional validation tests
await page.goto("/signup");
// Test with mismatched passwords
console.log("❌ Testing mismatched passwords...");
await page.getByPlaceholder("m@example.com").fill(generateTestEmail());
const passwordInputs = page.getByTitle("Password");
await passwordInputs.nth(0).fill("password1");
await passwordInputs.nth(1).fill("password2");
await page.getByRole("checkbox").click();
await page.getByRole("button", { name: "Sign up" }).click();
// Should still be on signup page
await expect(page).toHaveURL(/\/signup/);
console.log("✅ Mismatched passwords correctly blocked");
});
test("user can signup with custom credentials", async ({ page }) => {
console.log("🧪 Testing signup with custom credentials...");
try {
const customEmail = generateTestEmail();
const customPassword = generateTestPassword();
const testUser = await signupTestUser(page, customEmail, customPassword);
// Verify correct credentials were used
expect(testUser.email).toBe(customEmail);
expect(testUser.password).toBe(customPassword);
// Verify successful signup
await expect(page).toHaveURL("/marketplace");
await expect(
page.getByTestId("profile-popout-menu-trigger"),
).toBeVisible();
console.log(`✅ Custom credentials signup worked: ${testUser.email}`);
} catch (error) {
console.error("❌ Custom credentials signup test failed:", error);
}
});
test("user can signup with existing email handling", async ({ page }) => {
console.log("🧪 Testing duplicate email handling...");
try {
const testEmail = generateTestEmail();
const testPassword = generateTestPassword();
// First signup
console.log(`👤 First signup attempt: ${testEmail}`);
const firstUser = await signupTestUser(page, testEmail, testPassword);
expect(firstUser.email).toBe(testEmail);
console.log("✅ First signup successful");
// Second signup attempt with same email should handle gracefully
console.log(`👤 Second signup attempt: ${testEmail}`);
try {
await signupTestUser(page, testEmail, testPassword);
console.log(" Second signup handled gracefully");
} catch (_error) {
console.log(" Second signup rejected as expected");
}
console.log("✅ Duplicate email handling test completed");
} catch (error) {
console.error("❌ Duplicate email handling test failed:", error);
}
});
});

View File

@@ -0,0 +1,184 @@
import { faker } from "@faker-js/faker";
import { chromium, webkit } from "@playwright/test";
import fs from "fs";
import path from "path";
import { signupTestUser } from "./signup";
export interface TestUser {
email: string;
password: string;
id?: string;
createdAt?: string;
}
export interface UserPool {
users: TestUser[];
createdAt: string;
version: string;
}
// Using Playwright MCP server tools for browser automation
// No need to manage browser instances manually
/**
* Create a new test user through signup page using Playwright MCP server
* @param email - User email (optional, will generate if not provided)
* @param password - User password (optional, will generate if not provided)
* @param ignoreOnboarding - Skip onboarding and go to marketplace (default: true)
* @returns Promise<TestUser> - Created user object
*/
export async function createTestUser(
email?: string,
password?: string,
ignoreOnboarding: boolean = true,
): Promise<TestUser> {
const userEmail = email || faker.internet.email();
const userPassword = password || faker.internet.password({ length: 12 });
try {
const browserType = process.env.BROWSER_TYPE || "chromium";
const browser =
browserType === "webkit"
? await webkit.launch({ headless: true })
: await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
try {
const testUser = await signupTestUser(
page,
userEmail,
userPassword,
ignoreOnboarding,
);
return testUser;
} finally {
await page.close();
await context.close();
await browser.close();
}
} catch (error) {
console.error(`❌ Error creating test user ${userEmail}:`, error);
throw error;
}
}
/**
* Create multiple test users
* @param count - Number of users to create
* @returns Promise<TestUser[]> - Array of created users
*/
export async function createTestUsers(count: number): Promise<TestUser[]> {
console.log(`👥 Creating ${count} test users...`);
const users: TestUser[] = [];
for (let i = 0; i < count; i++) {
try {
const user = await createTestUser();
users.push(user);
console.log(`✅ Created user ${i + 1}/${count}: ${user.email}`);
} catch (error) {
console.error(`❌ Failed to create user ${i + 1}/${count}:`, error);
// Continue creating other users even if one fails
}
}
console.log(`🎉 Successfully created ${users.length}/${count} test users`);
return users;
}
/**
* Save user pool to file system
* @param users - Array of users to save
* @param filePath - Path to save the file (optional)
*/
export async function saveUserPool(
users: TestUser[],
filePath?: string,
): Promise<void> {
const defaultPath = path.resolve(process.cwd(), ".auth", "user-pool.json");
const finalPath = filePath || defaultPath;
// Ensure .auth directory exists
const dirPath = path.dirname(finalPath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
const userPool: UserPool = {
users,
createdAt: new Date().toISOString(),
version: "1.0.0",
};
try {
fs.writeFileSync(finalPath, JSON.stringify(userPool, null, 2));
console.log(`✅ Successfully saved user pool to: ${finalPath}`);
} catch (error) {
console.error(`❌ Failed to save user pool to ${finalPath}:`, error);
throw error;
}
}
/**
* Load user pool from file system
* @param filePath - Path to load from (optional)
* @returns Promise<UserPool | null> - Loaded user pool or null if not found
*/
export async function loadUserPool(
filePath?: string,
): Promise<UserPool | null> {
const defaultPath = path.resolve(process.cwd(), ".auth", "user-pool.json");
const finalPath = filePath || defaultPath;
console.log(`📖 Loading user pool from: ${finalPath}`);
try {
if (!fs.existsSync(finalPath)) {
console.log(`⚠️ User pool file not found: ${finalPath}`);
return null;
}
const fileContent = fs.readFileSync(finalPath, "utf-8");
const userPool: UserPool = JSON.parse(fileContent);
console.log(
`✅ Successfully loaded ${userPool.users.length} users from: ${finalPath}`,
);
console.log(`📅 User pool created at: ${userPool.createdAt}`);
console.log(`🔖 User pool version: ${userPool.version}`);
return userPool;
} catch (error) {
console.error(`❌ Failed to load user pool from ${finalPath}:`, error);
return null;
}
}
/**
* Clean up all test users from a pool
* Note: When using signup page method, cleanup removes the user pool file
* @param filePath - Path to load from (optional)
*/
export async function cleanupTestUsers(filePath?: string): Promise<void> {
const defaultPath = path.resolve(process.cwd(), ".auth", "user-pool.json");
const finalPath = filePath || defaultPath;
console.log(`🧹 Cleaning up test users...`);
try {
if (fs.existsSync(finalPath)) {
fs.unlinkSync(finalPath);
console.log(`✅ Deleted user pool file: ${finalPath}`);
} else {
console.log(`⚠️ No user pool file found to cleanup`);
}
} catch (error) {
console.error(`❌ Failed to cleanup user pool:`, error);
}
console.log(`🎉 Cleanup completed`);
}

View File

@@ -0,0 +1,94 @@
import { Page } from "@playwright/test";
import { LoginPage } from "../pages/login.page";
import { TestUser } from "../fixtures/test-user.fixture";
/**
* Utility functions for signin/authentication tests
*/
export class SigninUtils {
constructor(
private page: Page,
private loginPage: LoginPage,
) {}
/**
* Perform login and verify success
*/
async loginAndVerify(testUser: TestUser): Promise<void> {
console.log(`🔐 Logging in as: ${testUser.email}`);
await this.page.goto("/login");
await this.loginPage.login(testUser.email, testUser.password);
// Verify we're on marketplace
await this.page.waitForURL("/marketplace");
// Verify profile menu is visible (user is authenticated)
await this.page.getByTestId("profile-popout-menu-trigger").waitFor({
state: "visible",
timeout: 5000,
});
console.log("✅ Login successful");
}
/**
* Perform logout and verify success
*/
async logoutAndVerify(): Promise<void> {
console.log("🚪 Logging out...");
// Open profile menu
await this.page.getByTestId("profile-popout-menu-trigger").click();
// Wait for menu to be visible
await this.page.getByRole("button", { name: "Log out" }).waitFor({
state: "visible",
timeout: 5000,
});
// Click logout
await this.page.getByRole("button", { name: "Log out" }).click();
// Verify we're back on login page
await this.page.waitForURL("/login");
console.log("✅ Logout successful");
}
/**
* Complete authentication cycle: login -> logout -> login
*/
async fullAuthenticationCycle(testUser: TestUser): Promise<void> {
console.log("🔄 Starting full authentication cycle...");
// First login
await this.loginAndVerify(testUser);
// Logout
await this.logoutAndVerify();
// Login again
await this.loginAndVerify(testUser);
console.log("✅ Full authentication cycle completed");
}
/**
* Verify user is on marketplace and authenticated
*/
async verifyAuthenticated(): Promise<void> {
await this.page.waitForURL("/marketplace");
await this.page.getByTestId("profile-popout-menu-trigger").waitFor({
state: "visible",
timeout: 5000,
});
}
/**
* Verify user is on login page (not authenticated)
*/
async verifyNotAuthenticated(): Promise<void> {
await this.page.waitForURL("/login");
}
}

View File

@@ -0,0 +1,166 @@
import { faker } from "@faker-js/faker";
import { TestUser } from "./auth";
/**
* Create a test user through signup page for test setup
* @param page - Playwright page object
* @param email - User email (optional, will generate if not provided)
* @param password - User password (optional, will generate if not provided)
* @param ignoreOnboarding - Skip onboarding and go to marketplace (default: true)
* @returns Promise<TestUser> - Created user object
*/
export async function signupTestUser(
page: any,
email?: string,
password?: string,
ignoreOnboarding: boolean = true,
): Promise<TestUser> {
const userEmail = email || faker.internet.email();
const userPassword = password || faker.internet.password({ length: 12 });
try {
// Navigate to signup page
await page.goto("http://localhost:3000/signup");
// Wait for page to load
const emailInput = page.getByPlaceholder("m@example.com");
await emailInput.waitFor({ state: "visible", timeout: 10000 });
// Fill form
await emailInput.fill(userEmail);
const passwordInputs = page.getByTitle("Password");
await passwordInputs.nth(0).fill(userPassword);
await passwordInputs.nth(1).fill(userPassword);
// Agree to terms and submit
await page.getByRole("checkbox").click();
const signupButton = page.getByRole("button", { name: "Sign up" });
await signupButton.click();
// Wait for successful signup - could redirect to onboarding or marketplace
try {
// Wait for either onboarding or marketplace redirect
await Promise.race([
page.waitForURL(/\/onboarding/, { timeout: 15000 }),
page.waitForURL(/\/marketplace/, { timeout: 15000 }),
]);
} catch (error) {
console.error(
"❌ Timeout waiting for redirect, current URL:",
page.url(),
);
throw error;
}
const currentUrl = page.url();
// Handle onboarding or marketplace redirect
if (currentUrl.includes("/onboarding") && ignoreOnboarding) {
await page.goto("http://localhost:3000/marketplace");
await page.waitForLoadState("domcontentloaded", { timeout: 10000 });
}
// Verify we're on the expected final page
if (ignoreOnboarding || currentUrl.includes("/marketplace")) {
// Verify we're on marketplace
await page
.getByText(
"Bringing you AI agents designed by thinkers from around the world",
)
.waitFor({ state: "visible", timeout: 10000 });
// Verify user is authenticated (profile menu visible)
await page
.getByTestId("profile-popout-menu-trigger")
.waitFor({ state: "visible", timeout: 10000 });
}
const testUser: TestUser = {
email: userEmail,
password: userPassword,
createdAt: new Date().toISOString(),
};
return testUser;
} catch (error) {
console.error(`❌ Error creating test user ${userEmail}:`, error);
throw error;
}
}
/**
* Complete signup and navigate to marketplace
* @param page - Playwright page object from MCP server
* @param email - User email (optional, will generate if not provided)
* @param password - User password (optional, will generate if not provided)
* @returns Promise<TestUser> - Created user object
*/
export async function signupAndNavigateToMarketplace(
page: any,
email?: string,
password?: string,
): Promise<TestUser> {
console.log("🧪 Creating user and navigating to marketplace...");
// Create the user via signup and automatically navigate to marketplace
const testUser = await signupTestUser(page, email, password, true);
console.log("✅ User successfully created and authenticated in marketplace");
return testUser;
}
/**
* Validate signup form behavior
* @param page - Playwright page object from MCP server
* @returns Promise<void>
*/
export async function validateSignupForm(page: any): Promise<void> {
console.log("🧪 Validating signup form...");
await page.goto("http://localhost:3000/signup");
// Test empty form submission
console.log("❌ Testing empty form submission...");
const signupButton = page.getByRole("button", { name: "Sign up" });
await signupButton.click();
// Should still be on signup page
const currentUrl = page.url();
if (currentUrl.includes("/signup")) {
console.log("✅ Empty form correctly blocked");
} else {
console.log("⚠️ Empty form was not blocked as expected");
}
// Test invalid email
console.log("❌ Testing invalid email...");
await page.getByPlaceholder("m@example.com").fill("invalid-email");
await signupButton.click();
// Should still be on signup page
const currentUrl2 = page.url();
if (currentUrl2.includes("/signup")) {
console.log("✅ Invalid email correctly blocked");
} else {
console.log("⚠️ Invalid email was not blocked as expected");
}
console.log("✅ Signup form validation completed");
}
/**
* Generate unique test email
* @returns string - Unique test email
*/
export function generateTestEmail(): string {
return `test.${Date.now()}.${Math.random().toString(36).substring(7)}@example.com`;
}
/**
* Generate secure test password
* @returns string - Secure test password
*/
export function generateTestPassword(): string {
return faker.internet.password({ length: 12 });
}

View File

@@ -1,9 +1,42 @@
import { faker } from "@faker-js/faker";
import { TestUser } from "./auth";
export function generateUser() {
return {
email: faker.internet.email(),
password: faker.internet.password(),
name: faker.person.fullName(),
/**
* Generate a test user with random data
* @param options - Optional parameters to override defaults
* @returns TestUser object with generated data
*/
export function generateUser(options?: {
email?: string;
password?: string;
name?: string;
}): TestUser {
console.log("🎲 Generating test user...");
const user: TestUser = {
email: options?.email || faker.internet.email(),
password: options?.password || faker.internet.password({ length: 12 }),
createdAt: new Date().toISOString(),
};
console.log(`✅ Generated user: ${user.email}`);
return user;
}
/**
* Generate multiple test users
* @param count - Number of users to generate
* @returns Array of TestUser objects
*/
export function generateUsers(count: number): TestUser[] {
console.log(`👥 Generating ${count} test users...`);
const users: TestUser[] = [];
for (let i = 0; i < count; i++) {
users.push(generateUser());
}
console.log(`✅ Generated ${users.length} test users`);
return users;
}