mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
Merge branch 'swiftyos/sdk' into swiftyos/integrations
This commit is contained in:
7
.github/workflows/platform-frontend-ci.yml
vendored
7
.github/workflows/platform-frontend-ci.yml
vendored
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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. */
|
||||
|
||||
25
autogpt_platform/frontend/pnpm-lock.yaml
generated
25
autogpt_platform/frontend/pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
10
autogpt_platform/frontend/src/app/api/__generated__/models/postV1StopGraphExecution200.ts
generated
Normal file
10
autogpt_platform/frontend/src/app/api/__generated__/models/postV1StopGraphExecution200.ts
generated
Normal 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;
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
@@ -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 };
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
43
autogpt_platform/frontend/src/tests/global-setup.ts
Normal file
43
autogpt_platform/frontend/src/tests/global-setup.ts
Normal 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;
|
||||
113
autogpt_platform/frontend/src/tests/signup.spec.ts
Normal file
113
autogpt_platform/frontend/src/tests/signup.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
184
autogpt_platform/frontend/src/tests/utils/auth.ts
Normal file
184
autogpt_platform/frontend/src/tests/utils/auth.ts
Normal 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`);
|
||||
}
|
||||
94
autogpt_platform/frontend/src/tests/utils/signin.ts
Normal file
94
autogpt_platform/frontend/src/tests/utils/signin.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
166
autogpt_platform/frontend/src/tests/utils/signup.ts
Normal file
166
autogpt_platform/frontend/src/tests/utils/signup.ts
Normal 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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user